From 2bdad0947b148f95d42768ea5777dd8f3ab10cff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 17:48:10 +0900 Subject: [PATCH 001/441] =?UTF-8?q?fix(B07):=20=EB=AA=85=EB=A0=B9=ED=96=89?= =?UTF-8?q?=20=ED=86=A0=EA=B8=80=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상태막대 `명령행` 토글의 초기 상태를 `useState(true)` → `useState(false)` 로 변경 - 진입 시 `--cad-command-height` 가 `0px` 로 잡혀 캔버스 세로 공간 확보 - 토글 동작·명령행 구현 자체는 불변 Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/openwebcad/src/components/Toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx index 653ce154..c45c847c 100644 --- a/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Toolbar.tsx @@ -19,7 +19,7 @@ import { ViewControls } from './ViewControls'; export const Toolbar: FC = () => { useCadRefresh(); const [panelCollapsed, setPanelCollapsed] = useState(false); - const [commandLineVisible, setCommandLineVisible] = useState(true); + const [commandLineVisible, setCommandLineVisible] = useState(false); const activeTool = (getActiveToolActor()?.getSnapshot()?.context?.type ?? null) as Tool | null; -- 2.54.0 From 2e57d29156661c41c47b16cd28b2a9ea306f80e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 18:03:18 +0900 Subject: [PATCH 002/441] =?UTF-8?q?fix(B05,B03):=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EA=B0=92=20=EB=B6=80=EC=9E=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=E2=80=94=20=EC=8B=A4=ED=8C=A8=20=EB=A7=88=EC=BB=A4=C2=B7?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=A9=94=EC=9D=BC=C2=B7[=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94]=20=EA=B1=B0=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 파일 입력 직후 초기 계산값과 B05 [초기화] 결과가 다름. 원인 - 자동설계 체인이 5단계까지 전부 성공해야만 `save_initial_snapshot()` 호출. 중간에 깨지면 `initial_snapshot/` 미생성 → [초기화]가 복원 대신 재계산 폴백. - 재계산 폴백의 지표면 기준이 체인과 다름 — 체인은 stage 1 확정값 (`get_surface_confirmation_params`), [초기화]는 config 기본값 (`surface_confirmation_defaults`). 실측: 프로젝트 stage1 `classification`/5m 대 config `csf`/1m. - 체인이 깨져도 WF1 은 초기 분석 완료 메일을 그대로 발송. 수정 (2026-09-02 사용자 확정 — 부분 결과는 분석 안 됨과 다르지 않으므로 부분 스냅샷은 만들지 않음) - `initial_design.failed` 마커 신설 — 프로젝트 루트(스냅샷 4트리 밖), 실패 사유 기록. 체인 진입 시 옛 마커 제거, 실패 5지점 + 스냅샷 저장 실패에서 기록. - WF1 이 마커를 읽어 완료 메일 대신 `send_initial_design_failed_email()` 발송 (관리자 주소 `ADMIN_EMAIL`, 없으면 주소 없이 안내). - B05 [초기화]: 스냅샷 있으면 복원, 실패 마커 있으면 409 `initial_design_failed` 로 거부(DELETE 앞에서 반환 — 데이터 무변경), 옛 프로젝트만 종전 재계산 폴백. - 재계산 폴백의 지표면 기준을 `get_surface_confirmation_params()` 로 통일. 자체검증: `pytest tmp/tests/ -q` 366 passed / 14 skipped / 0 failed (+4). 공용 브라우저 실측 — 실패 프로젝트 [초기화] 409 응답·`routes` 행 무변경 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Email.py | 52 +++++++++++++++++++- B03_FileInput/B03_FileInput_Service_Chain.py | 27 ++++++++-- B03_FileInput/B03_FileInput_Service_WF1.py | 27 +++++++--- B05_Profile/B05_Profile_Api_Fetch.ts | 4 +- B05_Profile/B05_Profile_Router_Lifecycle.py | 43 +++++++++++++--- common_util/common_util_initial_snapshot.py | 41 +++++++++++++++ 6 files changed, 176 insertions(+), 18 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Email.py b/B03_FileInput/B03_FileInput_Email.py index 0d7d2001..4da46696 100644 --- a/B03_FileInput/B03_FileInput_Email.py +++ b/B03_FileInput/B03_FileInput_Email.py @@ -6,7 +6,7 @@ from typing import Any from uuid import UUID from common_util.common_util_email import send_email -from config.config_system import APP_PUBLIC_BASE_URL +from config.config_system import ADMIN_EMAIL, APP_PUBLIC_BASE_URL logger = logging.getLogger(__name__) @@ -285,3 +285,53 @@ async def send_analysis_error_email( ) logger.info("WF1 오류 이메일 발송 %s: %s", "성공" if success else "실패", to_email) return success + + +async def send_initial_design_failed_email( + *, + project_id: UUID, + project_name: str, + to_email: str, + reason: str, +) -> bool: + """초기 설계(B05·B06 자동 계산) 실패를 알리고 관리자 문의를 안내한다. + + 부분 결과를 초기값으로 삼지 않는다는 방침(2026-09-02 사용자 확정)에 따라, 체인이 + 깨진 프로젝트는 [초기화]가 되돌릴 기준이 없다. 완료 메일 대신 이 메일을 보내 + 사용자가 화면에서 헛작업을 하지 않게 한다. + """ + contact = ADMIN_EMAIL.strip() + contact_line = ( + f'

관리자에게 문의해 주세요 — ' + f"{html.escape(contact)}

" + if contact + else "

관리자에게 문의해 주세요.

" + ) + rows = "\n".join( + [ + _summary_row("프로젝트 ID", html.escape(str(project_id))), + _summary_row("중단 지점", html.escape(reason or "초기 설계 처리 실패")), + ] + ) + safe_name = html.escape(project_name) + body = f""" +

프로젝트 {safe_name}초기 설계 자동 계산이 + 완료되지 못했습니다.

+
+ {rows} +
+
+

초기 설계가 끝나지 않아 종·횡단 화면의 값이 기준값으로 확정되지 않았습니다. + 이 상태에서는 [초기화]로 되돌릴 초기값도 없습니다.

+ {contact_line} +
+

기술 지원팀이 확인할 수 있도록 서버 로그에도 같은 내용을 기록했습니다.

+ """ + subject = f"초기 설계 실패 알림 - {project_name}" + success = await send_email( + to_email=to_email, + subject=subject, + html=_email_shell(subject, body, accent="#dc2626"), + ) + logger.info("초기 설계 실패 이메일 발송 %s: %s", "성공" if success else "실패", to_email) + return success diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index de3a99b7..39bd3125 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -139,7 +139,9 @@ async def run_auto_design_chain( from B05_Profile.B05_Profile_Schema import RoutePoint, RouteSolveRequest from B06_Section.B06_Section_Router_Confirm import confirm_sections from common_util.common_util_initial_snapshot import ( + clear_design_failed, clear_designing, + mark_design_failed, mark_designing, save_initial_snapshot, ) @@ -167,6 +169,8 @@ async def run_auto_design_chain( # 이 체인이 끝나기 전에는 B05·B06에 들어가면 안 된다 — 반쯤 계산된 화면을 만지면 # 그 편집이 섞인 채 초기값이 찍힌다(2026-08-29 사용자 확정, CLAUDE.md 5장). mark_designing(project_root) + # 옛 실패 마커는 여기서 지운다 — 이번 체인의 결과로 다시 판정한다. + clear_design_failed(project_root) # WF1이 **실제로 확정한** 선택값(stage 1 스냅샷)을 그대로 쓴다. config 기본값을 # 쓰면 LAS 없이 설계한 프로젝트에서 있지도 않은 모델을 가리켜 404로 체인이 # 끊긴다(2026-08-30 실사고). 노선 트림도 이 지표면을 기준으로 한다. @@ -176,6 +180,7 @@ async def run_auto_design_chain( points = _planned_route_points_in_project_crs(project_root, defaults) if not points: logger.warning("자동 설계 체인 중단(계획노선 CSV 없음): project_id=%s", project_id) + mark_design_failed(project_root, "계획노선 CSV가 없어 초기 노선을 세울 수 없습니다.") return None # 3) B05 경로 계산 @@ -198,6 +203,10 @@ async def run_auto_design_chain( project_id, solve_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 계산 실패 (status={solve_result.status_code}).", + ) return None route_id = int(solve_result.route_id) logger.info( @@ -216,6 +225,10 @@ async def run_auto_design_chain( project_id, confirm_result.status_code, ) + mark_design_failed( + project_root, + f"B05 초기 노선 확정 실패 (status={confirm_result.status_code}).", + ) return None # 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출. @@ -234,6 +247,10 @@ async def run_auto_design_chain( route_id, sections_result.status_code, ) + mark_design_failed( + project_root, + f"B06 초기 횡단 확정 실패 (status={sections_result.status_code}).", + ) return None logger.info( "자동 설계 체인 완료(B05·B06 기본값 확정): project_id=%s route_id=%s", @@ -242,21 +259,25 @@ async def run_auto_design_chain( ) # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). - # 체인 규약대로 실패는 비치명적이다: 스냅샷이 없으면 [초기화]가 재계산으로 돈다. + # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 + # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). try: async with pool.acquire() as connection: await save_initial_snapshot(connection, project_root, route_id) - except Exception: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 + except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) + mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), "cross_section_count": solve_result.cross_section_count, } - except Exception: + except Exception as exc: # 체인은 업로드·WF1 흐름의 부가 작업이다 — 어떤 예외도 밖으로 던지지 않는다. logger.exception("자동 설계 체인 실패: project_id=%s", project_id) + if project_root is not None: + mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}") finally: # 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다. if project_root is not None: diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 125be8fd..5ffb7cb3 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -11,8 +11,10 @@ import aiomysql from B03_FileInput.B03_FileInput_Email import ( send_analysis_error_email, send_initial_analysis_complete_email, + send_initial_design_failed_email, ) from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_initial_snapshot import is_design_failed, read_design_failure from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import surface_confirmation_defaults from common_util.common_util_workflow_state import fail_stage, start_stage @@ -210,14 +212,25 @@ async def trigger_wf1_analysis_and_email( # 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을 # 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시). + # 체인이 깨졌으면 완료 메일 대신 관리자 문의 안내를 보낸다 — 부분 결과는 분석 + # 안 됨과 다르지 않다(2026-09-02 사용자 확정). + design_failure = read_design_failure(project_root) if is_design_failed(project_root) else "" if SEND_ANALYSIS_COMPLETION_EMAIL and project_info and project_info.get("user_email"): - await send_initial_analysis_complete_email( - project_id=project_id, - project_name=str(project_info.get("project_name") or project_id), - to_email=str(project_info["user_email"]), - analysis_result=analysis_result, - design_summary=design_summary, - ) + if design_failure: + await send_initial_design_failed_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + reason=design_failure, + ) + else: + await send_initial_analysis_complete_email( + project_id=project_id, + project_name=str(project_info.get("project_name") or project_id), + to_email=str(project_info["user_email"]), + analysis_result=analysis_result, + design_summary=design_summary, + ) except Exception as exc: logger.exception("WF1 백그라운드 분석 실패: project_id=%s", project_id) async with pool.acquire() as connection, connection.cursor() as cursor: diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index 05cd24bd..b137cef6 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -275,7 +275,9 @@ export interface RouteResetResponse { } /** [초기화] — 사용자 편집을 전부 버리고 초기값으로 되돌린다. 초기값 스냅샷이 있으면 - * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. */ + * 복원이라 빠르지만, 없는 옛 프로젝트는 재계산으로 폴백하므로 분석용 타임아웃을 쓴다. + * 초기 설계가 실패로 끝난 프로젝트는 서버가 409로 거부한다 — 되돌릴 기준이 없어 + * 재계산으로 얼버무리지 않는다(2026-09-02). 그 안내 문구가 그대로 오류 토스트에 뜬다. */ export async function resetRouteDesign(projectId: string): Promise { return requestJson( `/projects/${projectId}/route/reset`, diff --git a/B05_Profile/B05_Profile_Router_Lifecycle.py b/B05_Profile/B05_Profile_Router_Lifecycle.py index 1e087863..474b3426 100644 --- a/B05_Profile/B05_Profile_Router_Lifecycle.py +++ b/B05_Profile/B05_Profile_Router_Lifecycle.py @@ -149,20 +149,26 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: `edits/pipe_points.json`이 사용자 편집분인 채로 남아 구조물 측점이 그것에서 다시 파생되기 때문이다. - 스냅샷이 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 경로든 - 사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage 2·3은 - IN_PROGRESS(검토 대기)가 된다. + 초기 설계 체인이 **실패로 끝난 프로젝트**(`initial_design.failed` 마커)는 되돌릴 + 기준이 없다 — 재계산으로 얼버무리지 않고 409로 관리자 문의를 안내한다. 부분 결과는 + 분석 안 됨과 다르지 않다(2026-09-02 사용자 확정). + + 마커도 스냅샷도 없는 옛 프로젝트는 종전대로 자동 설계 체인을 다시 돌린다. 어느 + 경로든 사용자 편집(제어점 이동·계획선 편집·횡단 설계 지정)은 전부 버려지고, stage + 2·3은 IN_PROGRESS(검토 대기)가 된다. """ from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files from common_util.common_util_initial_snapshot import ( has_initial_snapshot, + is_design_failed, + read_design_failure, restore_initial_snapshot, restore_snapshot_files, wipe_edited_masters, ) - from common_util.common_util_surface_confirmation import surface_confirmation_defaults + from common_util.common_util_surface_confirmation import get_surface_confirmation_params pool = get_db_pool() try: @@ -170,12 +176,37 @@ async def reset_route_design(project_id: UUID) -> JSONResponse: stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) if stored_path else None restored = bool(project_root and has_initial_snapshot(project_root)) + if not restored and project_root and is_design_failed(project_root): + # 초기 설계가 깨진 프로젝트 — 되돌릴 기준이 없다. 재계산은 사용자가 본 초기 + # 화면과 다른 값을 낳으므로 하지 않는다(2026-09-02 사용자 확정). + reason = read_design_failure(project_root) or "초기 설계 처리 실패" + logger.warning( + "B05 초기화 거부(초기 설계 실패 프로젝트): project_id=%s reason=%s", + project_id, + reason, + ) + return JSONResponse( + status_code=409, + content={ + "status": "error", + "code": "initial_design_failed", + "reason": reason, + "message": ( + "초기 설계가 완료되지 않아 되돌릴 초기값이 없습니다. " + "관리자에게 문의해 주세요." + ), + }, + ) async with pool.acquire() as connection: - # 확정 지표면 모델을 초기 체인과 같은 기준(config 기본값)으로 다시 찾는다. + # 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1 + # 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을 + # 집어 재계산 결과가 초기값과 달라진다(2026-09-02 실측: `classification`/5m + # 확정 프로젝트에 `csf`/1m 기본값이 걸림). try: + selection = await get_surface_confirmation_params(connection, str(project_id)) surface_model_id: int | None = await find_surface_model_for_selection( - connection, project_id, surface_confirmation_defaults() + connection, project_id, selection ) except Exception: surface_model_id = None diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index 3420a60c..e9a56e5c 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -21,6 +21,8 @@ SNAPSHOT_DIRNAME = "initial_snapshot" _DB_DUMP_NAME = "db.json" # 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용). DESIGNING_LOCK_NAME = "initial_design.lock" +# 초기 설계 체인이 실패로 끝났음을 남기는 마커. +DESIGN_FAILED_NAME = "initial_design.failed" # 프로젝트 루트 기준 상대 경로 — 정본 파일이 사는 자리 전부. _FILE_TREES = ( @@ -69,6 +71,45 @@ def clear_designing(project_root: Path) -> None: pass +def design_failed_path(project_root: Path) -> Path: + """초기 설계 체인이 실패로 끝났음을 남기는 마커. + + "스냅샷이 없다"는 사실만으로는 **실패한 프로젝트**와 스냅샷 기능 이전의 **옛 + 프로젝트**를 가를 수 없다. 앞은 [초기화]가 재계산으로 얼버무리면 안 되고(부분 결과는 + 분석 안 됨과 다르지 않다, 2026-09-02 사용자 확정), 뒤는 종전 재계산 폴백이 유일한 + 수단이다. 락과 같은 자리 — 스냅샷 대상 4트리 **밖**이라 복원에 딸려 들어가지 않는다. + """ + return Path(project_root) / DESIGN_FAILED_NAME + + +def is_design_failed(project_root: Path) -> bool: + return design_failed_path(project_root).is_file() + + +def read_design_failure(project_root: Path) -> str: + """실패 사유를 읽는다. 마커가 없거나 못 읽으면 빈 문자열.""" + try: + return design_failed_path(project_root).read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def mark_design_failed(project_root: Path, reason: str) -> None: + path = design_failed_path(project_root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(reason, encoding="utf-8") + except OSError: + pass # 마커 실패가 체인을 막지는 않는다 — 안내가 덜 정확해질 뿐이다. + + +def clear_design_failed(project_root: Path) -> None: + try: + design_failed_path(project_root).unlink(missing_ok=True) + except OSError: + pass + + def discard_initial_snapshot(project_root: Path) -> bool: """초기값을 무효화한다 — 지표면·노선이 바뀌어 옛 초기값이 더는 기준이 아닐 때. -- 2.54.0 From 5fc800992543e3099bded27f3fcd1cb67e84ad04 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 18:04:44 +0900 Subject: [PATCH 003/441] =?UTF-8?q?feat(B01):=20=EC=84=9C=EB=AA=85=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EC=BA=94=EB=B2=84=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자산 모달 서명 칸에 마우스 그리기 판 신설 — pointer 이벤트 + setPointerCapture, getBoundingClientRect 로 CSS 크기 → 캔버스 픽셀 환산, [지우기] 1개 - 올릴 때 canvas.toBlob('image/png') → signature.png 로 기존 업로드 통로 (POST /company/assets) 재사용 — 백엔드·DB 변경 없음 - 파일을 안 고르면 그린 서명을 대신 실어 보냄, 로고 칸에는 캔버스 없음 - 검증(5174 공용 브라우저): 좌표 환산 실측 x 98–381 · y 38–111(요청 100–380 · 40–110), [지우기] 후 잉크 0, 저장 PNG 480×160 RGBA 5,250 bytes, 프로젝트 signature_asset_id 2→3 반영 확인 후 원복. pytest 143 passed Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 89 ++++++++++++++++++- B01_Dashboard/B01_Dashboard_UI_Style.css | 12 +++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 2f43dd9d..b2c642cb 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -69,6 +69,82 @@ export function createAssetField( return { root, value: () => selected?.id ?? null }; } +/** + * 서명 그리기 칸 — 마우스·펜으로 획을 긋고 PNG(투명 배경)로 넘긴다. + * 파일 업로드와 같은 통로(`POST /company/assets`)를 쓰므로 백엔드는 손대지 않는다. + */ +function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise } { + const root = document.createElement("div"); + root.className = "ui-field"; + const caption = document.createElement("label"); + caption.className = "ui-field__label"; + caption.textContent = "또는 마우스로 그리기"; + const canvas = document.createElement("canvas"); + canvas.width = 480; + canvas.height = 160; + canvas.className = "b01-dashboard__sign-pad"; + const context = canvas.getContext("2d"); + let drawn = false; + let drawing = false; + + if (context) { + context.lineWidth = 2.5; + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = "#111111"; + } + // 캔버스 좌표는 CSS 크기가 아니라 픽셀 크기 기준이라 비율로 환산한다. + const at = (event: PointerEvent): [number, number] => { + const box = canvas.getBoundingClientRect(); + return [ + ((event.clientX - box.left) * canvas.width) / box.width, + ((event.clientY - box.top) * canvas.height) / box.height, + ]; + }; + canvas.addEventListener("pointerdown", (event) => { + if (!context) return; + drawing = true; + drawn = true; + canvas.setPointerCapture(event.pointerId); + const [x, y] = at(event); + context.beginPath(); + context.moveTo(x, y); + }); + canvas.addEventListener("pointermove", (event) => { + if (!drawing || !context) return; + const [x, y] = at(event); + context.lineTo(x, y); + context.stroke(); + }); + const stop = (): void => { + drawing = false; + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); + + const clear = createButton({ + label: "지우기", + variant: "ghost", + onClick: () => { + context?.clearRect(0, 0, canvas.width, canvas.height); + drawn = false; + }, + }); + root.append(caption, canvas, clear); + + return { + root, + toFile: () => + new Promise((resolve) => { + if (!drawn) return resolve(null); + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], "signature.png", { type: "image/png" }) : null), + "image/png", + ); + }), + }; +} + function openAssetPickerModal( kind: CompanyAsset["kind"], assets: CompanyAsset[], @@ -152,12 +228,17 @@ function openAssetPickerModal( mineBox.type = "checkbox"; mineBox.checked = kind === "SIGNATURE"; mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`)); + const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ label: "올리고 선택", onClick: async () => { - const chosen = file.input.files?.[0]; if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); - if (!chosen) return file.setError("그림 파일을 고르세요."); + const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; + if (!chosen) { + return file.setError( + pad ? "그림 파일을 고르거나 서명을 그리세요." : "그림 파일을 고르세요.", + ); + } const form = new FormData(); form.append("kind", kind); form.append("label", label.input.value.trim()); @@ -178,7 +259,9 @@ function openAssetPickerModal( const actions = document.createElement("div"); actions.className = "b01-dashboard__actions"; actions.append(createButton({ label: "닫기", variant: "ghost", onClick: close })); - panel.append(heading, grid, addTitle, label.root, file.root, mine, add, actions); + panel.append(heading, grid, addTitle, label.root, file.root); + if (pad) panel.append(pad.root); + panel.append(mine, add, actions); modal.append(panel); document.body.append(modal); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index cd70421d..de0a5d3f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -241,6 +241,18 @@ object-fit: contain; } +/* 서명 그리기 판 — touch-action 을 끊어야 끌기가 스크롤로 새지 않는다 */ +.b01-dashboard__sign-pad { + width: 100%; + height: auto; + aspect-ratio: 3 / 1; + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + cursor: crosshair; + touch-action: none; +} + .b01-dashboard__check { display: block; margin: var(--spacing-8) 0 var(--spacing-16); -- 2.54.0 From 65d6fd261f8bdb15f8778491c902ce48864e70a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 18:12:28 +0900 Subject: [PATCH 004/441] =?UTF-8?q?fix(B07):=20=EB=8F=84=EA=B0=81=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=9D=A0=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EC=9D=B4=EA=B4=80=C2=B7=EA=B9=A8=EC=A7=84=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=EC=97=90=20=EB=A0=8C=EB=8D=94=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EC=A0=95=EC=A7=80=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 편집 띠를 CAD 위 절대배치에서 도면 목록 하단 액션 칸으로 옮김 — 1행 편집 띠, 2행 [도각 편집]·[현재 도면 확정] (리본과 겹쳐 문구·버튼이 찌그러지던 문제) - `drawImage` 가 'broken' 상태 그림에서 던지던 예외로 렌더 루프가 끊겨 이후 모든 도면이 백지로 남던 결함 해소 — 못 읽은 그림은 건너뜀 - `ImageEntity` 가 자리표시자(`{{회사로고}}`)를 절대 URL 로 굳히던 저장 경로 수정 — 원본 주소를 보관해 그대로 돌려주고, 자리표시자는 요청조차 하지 않음 - 버튼 줌(ZOOMIN·ZOOMOUT)이 배율만 바꿔 도면이 화면 밖으로 밀리던 것을 화면 중심 고정으로 수정 Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_UI_FrameEdit.ts | 7 ++- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 8 +++- .../B07_DesignDetail_UI_Style.css | 45 ++++++++++++++----- .../openwebcad/src/commands/commands.view.ts | 20 ++++++++- .../screenCanvas.drawController.ts | 5 +++ .../openwebcad/src/entities/ImageEntity.ts | 21 +++++++-- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index f7a1fafc..7a195985 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -19,7 +19,7 @@ import { export interface FrameTemplateEditor { /** 도면 목록 아래에 놓는 「도각 편집」 버튼. */ button: HTMLButtonElement; - /** 편집 중임을 알리는 CAD 화면 상단 띠 (평소엔 숨김). */ + /** 편집 중임을 알리는 띠 — 도면 목록 하단 액션 칸의 1행 (평소엔 숨김). */ banner: HTMLElement; /** 편집 중인가 — 도면 변경 알림(확정 해제)을 이 동안 막는 데 쓴다. */ isEditing: () => boolean; @@ -66,7 +66,10 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor variant: "ghost", onClick: () => leave(), }); - banner.append(finishButton, resetButton, cancelButton); + const bannerButtons = document.createElement("div"); + bannerButtons.className = "b07-frame-edit__buttons"; + bannerButtons.append(finishButton, resetButton, cancelButton); + banner.append(bannerButtons); const button = createButton({ label: "도각 편집", diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 807467e4..575a7fd1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -601,7 +601,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { }, onSaved: () => drawingCache.clear(), }); - cadHost.prepend(frameEditor.banner); window.addEventListener("message", (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return; @@ -668,7 +667,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로 // 쪼개지고 액션 줄은 스크롤 밖에 남는다(2026-08-18 사용자 지시, B04~B07 공통). confirmActions.className = "b07-drawing-actions ui-sidebar-actions"; - confirmActions.append(frameEditor.button, confirmButton); + // 액션 칸은 두 줄이다 — 1행 도각 편집 띠(평소 숨김), 2행 [도각 편집]·[현재 도면 확정] + // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). + const confirmButtonRow = document.createElement("div"); + confirmButtonRow.className = "b07-drawing-actions__row"; + confirmButtonRow.append(frameEditor.button, confirmButton); + confirmActions.append(frameEditor.banner, confirmButtonRow); drawingPanel.append(infoPanelHost, confirmActions); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index f2ca6ee6..c9a95180 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -126,11 +126,25 @@ /* 하단 고정·배경·상단 구분선은 공용 ui-sidebar-actions가 맡는다(2026-08-18 통합). 여기서는 B07 고유 여백만 남긴다. */ +/* 액션 칸은 세로 두 줄 — 1행 도각 편집 띠, 2행 [도각 편집]·[현재 도면 확정]. + 공용 .ui-sidebar-actions는 가로 한 줄이라 방향과 늘어남을 여기서 되돌린다. */ .b07-drawing-actions { + flex-direction: column; padding-top: var(--spacing-12); } -.b07-drawing-actions > button { +.b07-drawing-actions > * { + flex: none; +} + +.b07-drawing-actions__row { + display: flex; + gap: var(--spacing-8); +} + +.b07-drawing-actions__row > button { + flex: 1 1 0; + min-width: 0; width: 100%; } @@ -271,29 +285,36 @@ color: var(--color-text-muted); } -/* 도각 편집 모드 띠 — CAD 위에 겹쳐 편집 중임을 알리고 [완료]·[취소]를 준다. */ +/* 도각 편집 모드 띠 — 도면 목록 하단 액션 칸의 1행. CAD 위에 떠 있던 배치는 리본과 + 겹쳐 문구가 접히고 버튼이 찌그러졌다(2026-09-02 사용자 지시로 사이드바로 옮김). */ .b07-frame-edit { - position: absolute; - z-index: 3; - top: 8px; - left: 50%; - transform: translateX(-50%); display: flex; - align-items: center; + flex-direction: column; gap: var(--spacing-8); - padding: 6px 10px; + padding: var(--spacing-8); border: 1px solid var(--color-border); border-radius: var(--radius-cards); background-color: var(--color-surface); - box-shadow: 0 6px 18px rgb(0 0 0 / 25%); +} + +.b07-frame-edit[hidden] { + display: none; } .b07-frame-edit__label { font-size: 0.78rem; + line-height: 1.4; color: var(--color-text); } -.b07-frame-edit > button { - padding: 4px 12px; +.b07-frame-edit__buttons { + display: flex; + gap: var(--spacing-8); +} + +.b07-frame-edit__buttons > button { + flex: 1 1 0; + min-width: 0; + padding: 4px 8px; font-size: 0.78rem; } diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts index 6f1fd7ad..0c21cede 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.view.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.view.ts @@ -1,4 +1,5 @@ /** 뷰 탭 — 탐색·재생성 명령 (조사표 7절 중 구현분) */ +import { Point } from '@flatten-js/core'; import { toast } from 'react-toastify'; import type { CadCommand } from './command.types'; import { bumpSceneVersion } from '../helpers/scene-version'; @@ -8,8 +9,23 @@ import { selectToolStateMachine } from '../tools/select-tool'; const zoomBy = (factor: number): string => { const controller = getScreenCanvasDrawController(); - controller.setScreenScale(Math.max(0.01, controller.getScreenScale() * factor)); - return `줌 ${Math.round(controller.getScreenScale() * 100)}%`; + const oldScale = controller.getScreenScale(); + const newScale = Math.max(0.01, oldScale * factor); + // 배율만 바꾸면 화면이 월드 원점 쪽으로 늘어나 도면이 화면 밖으로 밀려난다. + // 휠 줌이 커서 밑 좌표를 붙잡아 두듯, 버튼 줌은 **화면 중심**의 월드 좌표를 + // 붙잡아 둔다 (screen = (world - offset) * scale). + const canvasSize = controller.getCanvasSize(); + const offset = controller.getScreenOffset(); + const worldCenterX = offset.x + canvasSize.x / 2 / oldScale; + const worldCenterY = offset.y + canvasSize.y / 2 / oldScale; + controller.setScreenScale(newScale); + controller.setScreenOffset( + new Point( + worldCenterX - canvasSize.x / 2 / newScale, + worldCenterY - canvasSize.y / 2 / newScale + ) + ); + return `줌 ${Math.round(newScale * 100)}%`; }; export const VIEW_COMMANDS: CadCommand[] = [ diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 4facbdbd..2afd797f 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -596,6 +596,11 @@ export class ScreenCanvasDrawController implements DrawController { angle: number ): void { if (this.batching) this.flushBatch(); + // 못 읽은 그림은 건너뛴다. 'broken' 상태의 그림을 그리려 하면 캔버스가 예외를 + // 던지고, 그 예외가 렌더 루프를 끊어 **이후 모든 도면이 백지**로 남았다 + // (2026-09-02 실측: 도각 자리표시자가 404 로 깨진 채 줌 한 번에 화면이 멈췄다). + // 자리 하나가 비는 것과 도면 전체가 안 나오는 것은 무게가 다르다. + if (imageElement.complete === false || imageElement.naturalWidth === 0) return; // 크기는 배율만 곱한다. 예전에는 (width, height)를 좌표처럼 변환해 화면 오프셋과 // y 뒤집기가 섞여 들어갔고, 그림이 제 자리를 벗어나 비율까지 무너졌다(2026-09-02 // 도각 로고·서명에서 드러남). 자리는 SVG 컨트롤러와 같이 **세계 중심**으로 잡는다. diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index 3476aa8b..cca181dd 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -26,6 +26,13 @@ export class ImageEntity implements Entity { private imageElement: HTMLImageElement; private polygon: Polygon; private angle: number; + /** + * JSON에서 받은 그림 주소 원본. `imageElement.currentSrc`는 브라우저가 절대 URL로 + * 바꿔 놓아, 도각의 자리표시자(`{{회사로고}}`)가 저장 한 번에 + * `http://…/b07-cad/%7B%7B회사로고%7D%7D`로 굳는다(2026-09-02 실측 — 회사 도각이 + * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. + */ + private sourceData: string | null = null; constructor( layerId: string, @@ -115,7 +122,9 @@ export class ImageEntity implements Entity { public clone(): ImageEntity { const clonedImage = document.createElement('img'); clonedImage.src = this.imageElement.src; - return new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone()); + const cloned = new ImageEntity(getActiveLayerId(), clonedImage, this.polygon.clone()); + cloned.sourceData = this.sourceData; + return cloned; } // TODO add destroy method to cleanup this.imageElement.src @@ -230,7 +239,7 @@ export class ImageEntity implements Entity { x: vertex.x, y: vertex.y, })), - imageData: this.imageElement.currentSrc, + imageData: this.sourceData ?? this.imageElement.currentSrc, }, }; } @@ -243,7 +252,12 @@ export class ImageEntity implements Entity { jsonEntity.shapeData.points.map((point) => new Point(point.x, point.y)) ); const image = new Image(); - image.src = jsonEntity.shapeData.imageData; + // 자리표시자는 주소가 아니다 — 그대로 넣으면 404 요청이 나가고 그림이 'broken' + // 상태가 된다. 그 상태의 그림을 그리려 하면 캔버스가 예외를 던져 렌더 루프가 + // 끊기고 **이후 모든 도면이 백지**로 남았다(2026-09-02 실측). 자리만 남긴다. + if (!jsonEntity.shapeData.imageData.includes('{{')) { + image.src = jsonEntity.shapeData.imageData; + } const rectangleEntity = new ImageEntity( jsonEntity.layerId || getActiveLayerId(), image, @@ -253,6 +267,7 @@ export class ImageEntity implements Entity { rectangleEntity.lineColor = jsonEntity.lineColor; rectangleEntity.lineWidth = jsonEntity.lineWidth; rectangleEntity.lineDash = jsonEntity.lineDash; + rectangleEntity.sourceData = jsonEntity.shapeData.imageData; return rectangleEntity; } } -- 2.54.0 From 3a36af9a2cf2c1daae841776adf4d3e20c9f9ebf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 18:57:16 +0900 Subject: [PATCH 005/441] =?UTF-8?q?feat(B01):=20=EC=84=9C=EB=AA=85?= =?UTF-8?q?=EC=9D=80=20=EC=82=AC=EB=9E=8C=20=EA=B3=84=EC=A0=95=EC=97=90,?= =?UTF-8?q?=20=ED=9A=8C=EC=82=AC=20=EB=A1=9C=EA=B3=A0=EB=8A=94=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EB=93=B1=EB=A1=9D=20=EB=8B=A8=EA=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-02) — 이름이 들어가는 자리는 서명도 받고, 담당자 선택에 신규 등록 항목을 두며, 회사 로고는 회사 등록 단계에서 받아 이후 변경. - 014_company_logo.sql: companies.logo_asset_id 추가 (서명은 company_assets.user_id 로 이미 표현되어 컬럼 없음). 공유 DB 적용 완료 - 사용자 수정 모달에 「서명 (도면 표제란)」 칸 — createAssetField 에 owner(주인 못박기)· onChange(즉시 반영) 추가로 재사용, 주인 고정 시 물리기 체크 잠금 - 담당자 select 3개에 「+ 신규 등록…」 — 계정 생성 모달 뒤 세 select 에 항목 삽입·자동 선택 - POST /admin/members 가 이름·직위·부서 수신, 계정 없으면 status=PENDING 으로 생성 (로그인 불가 비밀번호). 700줄 제한으로 B01_Dashboard_Repository_Members.py 분리 - 회사 등록 모달에 로고 파일 칸, PUT /company/logo 신설, 회사 패널·회사 목록에서 변경 - 프로젝트 수정 모달의 「설계자 서명」 칸 제거 — signature_asset_id 는 null 로 고정 저장 - 검증(5174 실조작): 신규 등록 구성원 2→3(id 6 PENDING)·select 자동 선택, 서명 자산 [4,'검증신규 서명',user_id 6] 생성, 회사 logo_asset_id None→1, 검증 자산·계정 정리. pytest 143 passed, tsc·ruff·prettier 통과 Co-Authored-By: Claude Opus 5 (1M context) --- B01_Dashboard/B01_Dashboard_Api_Fetch.ts | 38 ++++- B01_Dashboard/B01_Dashboard_Repository.py | 56 +------- .../B01_Dashboard_Repository_Members.py | 111 ++++++++++++++ B01_Dashboard/B01_Dashboard_Router.py | 40 +++++- B01_Dashboard/B01_Dashboard_Schema.py | 10 ++ B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts | 68 +++++++-- B01_Dashboard/B01_Dashboard_UI_Company.ts | 48 ++++++- B01_Dashboard/B01_Dashboard_UI_Modals.ts | 135 ++++++++++++++---- B01_Dashboard/B01_Dashboard_UI_Page.ts | 2 +- db_management/014_company_logo.sql | 20 +++ 10 files changed, 430 insertions(+), 98 deletions(-) create mode 100644 B01_Dashboard/B01_Dashboard_Repository_Members.py create mode 100644 db_management/014_company_logo.sql diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 1d55c657..6aec62b9 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -68,6 +68,7 @@ export interface CompanyInfo { business_address?: string | null; business_owner?: string | null; business_status?: string | null; + logo_asset_id?: number | null; user_count?: number; project_count?: number; } @@ -232,7 +233,9 @@ export async function searchCompanies(query: string): Promise { return data.companies; } -export function createCompany(payload: CreateCompanyRequest): Promise { +export function createCompany( + payload: CreateCompanyRequest, +): Promise<{ company_id: number; status: string }> { return request("/dashboard/user/company/create", { method: "POST", body: body(payload) }); } @@ -277,6 +280,17 @@ export async function createCompanyAsset(form: FormData): Promise { return data.asset_id; } +/** 자산의 이름·주인을 고친다. 서명을 사람 계정에 물릴 때 쓴다 (2026-09-02). */ +export function updateCompanyAsset( + assetId: number, + payload: { label: string; user_id: number | null }, +): Promise { + return request(`/dashboard/company/assets/${assetId}`, { + method: "PUT", + body: body(payload), + }); +} + export function deleteCompanyAsset(assetId: number): Promise { return request(`/dashboard/company/assets/${assetId}`, { method: "DELETE" }); } @@ -284,8 +298,26 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; -export function addCompanyMember(email: string): Promise { - return request("/dashboard/admin/members", { method: "POST", body: body({ email }) }); +/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */ +export function addCompanyMember( + email: string, + profile?: { name?: string; position?: string | null; department?: string | null }, +): Promise<{ member: Member }> { + return request("/dashboard/admin/members", { + method: "POST", + body: body({ email, ...profile }), + }); +} + +/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */ +export function setCompanyLogo( + logoAssetId: number | null, + companyId?: number | null, +): Promise { + return request(`/dashboard/company/logo${companyQuery(companyId)}`, { + method: "PUT", + body: body({ logo_asset_id: logoAssetId }), + }); } export function removeCompanyMember(userId: number): Promise { diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index b78a2d69..ec13d932 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -289,7 +289,7 @@ async def get_user_company(company_id: int | None) -> dict[str, Any] | None: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT c.id, c.name, c.business_registration_number, c.business_address, - c.business_owner, c.business_status, + c.business_owner, c.business_status, c.logo_asset_id, COUNT(DISTINCT u.id) AS user_count, COUNT(DISTINCT p.id) AS project_count FROM companies c @@ -409,58 +409,6 @@ async def join_company(user_id: int, company_id: int) -> dict[str, Any]: raise -async def list_company_members(company_id: int) -> list[dict[str, Any]]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, email, name, position, department, role, is_master, status - FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""", - (company_id,), - ) - rows = list(await cursor.fetchall()) - for row in rows: - row["role"] = _role(row.get("role")) - row["is_master"] = bool(row.get("is_master")) - return rows - - -async def add_company_member(company_id: int, email: str) -> dict[str, Any] | None: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await connection.begin() - await cursor.execute( - """SELECT id, company_id FROM users - WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", - (email.lower(),), - ) - user = await cursor.fetchone() - if not user or user["company_id"] == company_id: - await connection.rollback() - return None - await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', - is_master = FALSE - WHERE id = %s""", - (company_id, user["id"]), - ) - await connection.commit() - return await get_dashboard_me(user["id"]) - - -async def remove_company_member(company_id: int, user_id: int) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER', - is_master = FALSE - WHERE id = %s AND company_id = %s AND is_master = FALSE""", - (user_id, company_id), - ) - changed = cursor.rowcount > 0 - await connection.commit() - return changed - - async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: where = "WHERE jr.company_id = %s" if company_id else "" params = (company_id,) if company_id else () @@ -520,7 +468,7 @@ async def list_all_companies() -> list[dict[str, Any]]: async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """SELECT c.id, c.name, c.business_registration_number, c.business_status, - c.created_at, COUNT(DISTINCT u.id) AS user_count, + c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count, COUNT(DISTINCT p.id) AS project_count FROM companies c LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py new file mode 100644 index 00000000..7a47abfe --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -0,0 +1,111 @@ +"""회사 구성원과 회사 대표 로고 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한). + +구성원은 도면 표제란의 사람 자리(과업책임자·분야별책임자·설계자)를 채우는 원천이라 +자산·로고와 같은 결로 묶어 둔다. +""" + +from __future__ import annotations + +import secrets +from typing import Any + +import aiomysql + +from common_util.common_util_auth import hash_password +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import _role, get_dashboard_me + + +async def list_company_members(company_id: int) -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, email, name, position, department, role, is_master, status + FROM users WHERE company_id = %s AND deleted_at IS NULL ORDER BY name, email""", + (company_id,), + ) + rows = list(await cursor.fetchall()) + for row in rows: + row["role"] = _role(row.get("role")) + row["is_master"] = bool(row.get("is_master")) + return rows + + +async def add_company_member( + company_id: int, + email: str, + profile: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """회사에 사람을 붙인다. + + `profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다** + (2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는 + 비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + await cursor.execute( + """SELECT id, company_id FROM users + WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", + (email.lower(),), + ) + user = await cursor.fetchone() + if not user and profile and profile.get("name"): + await cursor.execute( + """INSERT INTO users (email, password_hash, name, position, department, + company_id, role, status) + VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""", + ( + email.lower(), + hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호 + profile["name"], + profile.get("position"), + profile.get("department"), + company_id, + ), + ) + await connection.commit() + return await get_dashboard_me(cursor.lastrowid) + if not user or user["company_id"] == company_id: + await connection.rollback() + return None + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', + is_master = FALSE + WHERE id = %s""", + (company_id, user["id"]), + ) + await connection.commit() + return await get_dashboard_me(user["id"]) + + +async def remove_company_member(company_id: int, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users SET company_id = NULL, status = 'NO_COMPANY', role = 'USER', + is_master = FALSE + WHERE id = %s AND company_id = %s AND is_master = FALSE""", + (user_id, company_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + +async def set_company_logo(company_id: int, asset_id: int | None) -> bool: + """회사 대표 로고를 지정한다 (2026-09-02 사용자 확정 — 회사 등록 단계에서 받고 변경). + + `asset_id` 가 같은 회사의 `kind='LOGO'` 자산인지는 라우터가 확인한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "UPDATE companies SET logo_asset_id = %s WHERE id = %s AND deleted_at IS NULL", + (asset_id, company_id), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 46ad1b6d..7dd7b2a0 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -12,7 +12,6 @@ from common_util.common_util_storage import read_stored_asset from config.config_system import PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Repository import ( - add_company_member, assign_user_company, change_user_role, create_company, @@ -26,12 +25,10 @@ from .B01_Dashboard_Repository import ( list_all_projects, list_all_users, list_audit_logs, - list_company_members, list_company_projects, list_join_requests, list_user_projects, process_join_request, - remove_company_member, search_companies, soft_delete_project, update_admin_user, @@ -49,6 +46,12 @@ from .B01_Dashboard_Repository_Assets import ( update_company_asset, write_company_asset_file, ) +from .B01_Dashboard_Repository_Members import ( + add_company_member, + list_company_members, + remove_company_member, + set_company_logo, +) from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, @@ -58,6 +61,7 @@ from .B01_Dashboard_Schema import ( JoinCompanyRequest, ProcessJoinRequest, UpdateCompanyAssetRequest, + UpdateCompanyLogoRequest, UpdateProjectRequest, UpdateUserRequest, ) @@ -210,7 +214,15 @@ async def admin_add_member( payload: AddMemberRequest, session: dict[str, Any] = Depends(require_company_admin), ): - member = await add_company_member(_require_company_id(session), payload.email) + member = await add_company_member( + _require_company_id(session), + payload.email, + { + "name": payload.name, + "position": payload.position, + "department": payload.department, + }, + ) if not member: raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") return {"status": "success", "member": member} @@ -475,6 +487,26 @@ async def company_delete_asset(asset_id: int, session: dict[str, Any] = Depends( return {"status": "success"} +@router.put("/company/logo") +async def company_set_logo( + payload: UpdateCompanyLogoRequest, + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + """회사 대표 로고를 지정·변경한다 (2026-09-02 사용자 확정). + + 프로젝트가 따로 고르지 않으면 도면은 이 로고를 쓴다. + """ + scoped = _scope_company(session, company_id) + if payload.logo_asset_id is not None: + asset = await get_company_asset(payload.logo_asset_id) + if not asset or asset["company_id"] != scoped or asset["kind"] != "LOGO": + raise HTTPException(status_code=400, detail="같은 회사의 로고 자산이어야 합니다.") + if not await set_company_logo(scoped, payload.logo_asset_id): + raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") + return {"status": "success"} + + @router.get("/company/assets/{asset_id}/file") async def company_asset_file(asset_id: int, session: dict[str, Any] = Depends(verify_session)): """목록 미리보기용 그림. 도면에는 B07 이 같은 파일을 data URL 로 심는다.""" diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index d5d40b6f..3e69affb 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -25,6 +25,16 @@ class JoinCompanyRequest(BaseModel): class AddMemberRequest(BaseModel): email: str = Field(min_length=3, max_length=255) + # 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면 + # 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다. + name: str | None = Field(default=None, max_length=100) + position: str | None = Field(default=None, max_length=100) + department: str | None = Field(default=None, max_length=100) + + +class UpdateCompanyLogoRequest(BaseModel): + # 회사 대표 로고 (company_assets.id, kind=LOGO). None 이면 지정을 지운다. + logo_asset_id: int | None = Field(default=None, gt=0) class ProcessJoinRequest(BaseModel): diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index b2c642cb..d16b079c 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -20,6 +20,13 @@ export interface AssetFieldHandle { value: () => number | null; } +export interface AssetFieldOptions { + /** 이 자산의 주인을 사람으로 못박는다 — 사용자 서명 칸(2026-09-02 사용자 확정). */ + owner?: { id: number; name: string } | null; + /** 고른 뒤 바로 서버에 반영해야 하는 자리(회사 로고·사용자 서명)에서 쓴다. */ + onChange?: (assetId: number | null) => void | Promise; +} + const KIND_LABEL = { LOGO: "로고", SIGNATURE: "서명" } as const; /** 수정 모달 안의 한 칸: 현재 고른 자산 미리보기 + [선택…] 버튼. */ @@ -30,8 +37,14 @@ export function createAssetField( initialId: number | null | undefined, companyId: number | null | undefined, user: DashboardUser, + options: AssetFieldOptions = {}, ): AssetFieldHandle { - let list = assets.filter((asset) => asset.kind === kind); + const owner = options.owner ?? null; + // 주인이 못박힌 칸은 그 사람 것(또는 아직 주인 없는 것)만 보인다. + let list = assets.filter( + (asset) => + asset.kind === kind && (!owner || asset.user_id === owner.id || asset.user_id == null), + ); let selected = list.find((asset) => asset.id === initialId) ?? null; const root = document.createElement("div"); @@ -58,11 +71,20 @@ export function createAssetField( label: "선택…", variant: "ghost", onClick: () => - openAssetPickerModal(kind, list, selected?.id ?? null, companyId, user, (next, fresh) => { - list = fresh; - selected = next; - render(); - }), + openAssetPickerModal( + kind, + list, + selected?.id ?? null, + companyId, + user, + (next, fresh) => { + list = fresh; + selected = next; + render(); + void options.onChange?.(next?.id ?? null); + }, + owner, + ), }); row.append(preview, name, pick); root.append(caption, row); @@ -145,6 +167,28 @@ function createSignaturePad(): { root: HTMLDivElement; toFile: () => Promise void | Promise, +): Promise { + const assets = await fetchCompanyAssets(companyId); + openAssetPickerModal( + kind, + assets.filter((asset) => asset.kind === kind), + currentId, + companyId, + user, + (next) => void onPick(next?.id ?? null), + ); +} + function openAssetPickerModal( kind: CompanyAsset["kind"], assets: CompanyAsset[], @@ -152,6 +196,7 @@ function openAssetPickerModal( companyId: number | null | undefined, user: DashboardUser, onPick: (asset: CompanyAsset | null, list: CompanyAsset[]) => void, + owner: { id: number; name: string } | null = null, ): void { let list = assets; const modal = document.createElement("div"); @@ -226,8 +271,13 @@ function openAssetPickerModal( mine.className = "b01-dashboard__check"; const mineBox = document.createElement("input"); mineBox.type = "checkbox"; - mineBox.checked = kind === "SIGNATURE"; - mine.append(mineBox, document.createTextNode(` 내 계정(${user.name})에 물리기`)); + mineBox.checked = owner !== null || kind === "SIGNATURE"; + // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. + mineBox.disabled = owner !== null; + mine.append( + mineBox, + document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`), + ); const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ label: "올리고 선택", @@ -243,7 +293,7 @@ function openAssetPickerModal( form.append("kind", kind); form.append("label", label.input.value.trim()); form.append("file", chosen); - if (mineBox.checked) form.append("user_id", String(user.id)); + if (mineBox.checked) form.append("user_id", String(owner ? owner.id : user.id)); if (companyId) form.append("company_id", String(companyId)); try { const id = await createCompanyAsset(form); diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 99509b9d..1da33720 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -1,11 +1,14 @@ import { createButton, createTag } from "@ui/ui_template_elements"; import { + fetchCompanyAssets, processJoinRequest, + setCompanyLogo, type CompanyInfo, type DashboardUser, type JoinRequest, type Member, } from "./B01_Dashboard_Api_Fetch"; +import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicker"; import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, @@ -41,6 +44,26 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement { text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), ); + // 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정). + // 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다. + if (state.user.role !== "USER") { + const company = state.company; + const slot = document.createElement("div"); + void fetchCompanyAssets(company.id).then((assets) => { + slot.append( + createAssetField( + "회사 로고 (도면 표제란)", + "LOGO", + assets, + company.logo_asset_id ?? null, + company.id, + state.user, + { onChange: (assetId) => setCompanyLogo(assetId, company.id).then(() => undefined) }, + ).root, + ); + }); + wrap.append(slot); + } return wrap; } @@ -122,17 +145,40 @@ export function joinRequestTable(requests: JoinRequest[], systemMode: boolean): ); } -export function companyTable(companies: CompanyInfo[]): HTMLElement { +export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HTMLElement { + // 로고는 회사 등록 단계에서 받고 여기서 바꾼다 (2026-09-02 사용자 확정). + const logoCell = (company: CompanyInfo): HTMLElement => { + if (!user) return text(""); + const button = createButton({ + label: company.logo_asset_id ? "로고 변경…" : "로고 지정…", + variant: "ghost", + onClick: () => + void openAssetPicker( + "LOGO", + company.id, + user, + company.logo_asset_id ?? null, + async (id) => { + await setCompanyLogo(id, company.id); + company.logo_asset_id = id; + button.textContent = id ? "로고 변경…" : "로고 지정…"; + }, + ), + }); + return button; + }; return table( [ L("B01_Dashboard_Table_Company"), L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), + "로고", ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), + logoCell(company), ]), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 9d114aae..9a111b49 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -19,12 +19,18 @@ import { addCompanyMember, fetchCompanyMembers, fetchCompanyAssets, + updateCompanyAsset, + createCompanyAsset, + setCompanyLogo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ +const NEW_MEMBER = "__new__"; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -122,15 +128,42 @@ export async function openEditProjectModal( type: "date", value: (project.design_date ?? "").slice(0, 10), }); + const memberText = (member: Member) => + member.position ? `${member.name} (${member.position})` : member.name; const personOptions = [ { value: "", text: "(미지정)" }, - ...members.map((member) => ({ - value: String(member.id), - text: member.position ? `${member.name} (${member.position})` : member.name, - })), + ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), + { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; - const person = (label: string, current: number | null | undefined) => - createSelectField({ label, options: personOptions, value: String(current ?? "") }); + const persons: HTMLSelectElement[] = []; + const person = (label: string, current: number | null | undefined) => { + const field = createSelectField({ + label, + options: personOptions, + value: String(current ?? ""), + }); + persons.push(field.select); + let last = field.select.value; + // 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다. + field.select.addEventListener("change", () => { + if (field.select.value !== NEW_MEMBER) { + last = field.select.value; + return; + } + field.select.value = last; + openAddMemberModal((member) => { + for (const select of persons) { + const option = document.createElement("option"); + option.value = String(member.id); + option.textContent = memberText(member); + select.insertBefore(option, select.options[select.options.length - 1]); + } + field.select.value = String(member.id); + last = field.select.value; + }); + }); + return field; + }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); const fieldLead = person("분야별책임자 (도면 표제란)", project.field_lead_user_id); const designer = person("설계자 (도면 표제란)", project.designer_user_id); @@ -142,15 +175,6 @@ export async function openEditProjectModal( project.company_id, user, ); - const signature = createAssetField( - "설계자 서명 (도면 표제란)", - "SIGNATURE", - assets, - project.signature_asset_id, - project.company_id, - user, - ); - if (isUserOnly) { name.input.disabled = true; region.input.disabled = true; @@ -185,7 +209,6 @@ export async function openEditProjectModal( fieldLead.root, designer.root, logo.root, - signature.root, ); const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); @@ -206,7 +229,8 @@ export async function openEditProjectModal( field_lead_user_id: userId(fieldLead.select), designer_user_id: userId(designer.select), logo_asset_id: logo.value(), - signature_asset_id: signature.value(), + // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. + signature_asset_id: null, }); showToast(L("B01_Dashboard_Saved"), "success"); }); @@ -244,6 +268,33 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar const phoneVal = (target as DashboardUser).phone || ""; const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); + // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 + // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. + const signatureSlot = document.createElement("div"); + const companyId = (target as DashboardUser).company_id ?? user.company_id; + void fetchCompanyAssets(companyId).then((assets) => { + const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id); + signatureSlot.append( + createAssetField( + "서명 (도면 표제란)", + "SIGNATURE", + assets, + owned?.id ?? null, + companyId, + user, + { + owner: { id: target.id, name: target.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); + }, + }, + ).root, + ); + }); + if (user.role === "ADMIN") { // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 name.input.disabled = true; @@ -257,7 +308,7 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar openModal( L("B01_Dashboard_EditUser"), - [name.root, position.root, department.root, phone.root], + [name.root, position.root, department.root, phone.root, signatureSlot], async () => { await updateDashboardUser(target.id, { name: name.input.value.trim(), @@ -306,18 +357,32 @@ export function openCreateCompanyModal(): void { }); const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); + // 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다. + const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" }); + logo.input.type = "file"; + logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg"; openModal( L("B01_Dashboard_Modal_CreateCompany"), - [name.root, number.root, address.root, owner.root], + [name.root, number.root, address.root, owner.root, logo.root], async () => { if (!name.input.value.trim() || !number.input.value.trim()) return; - await createCompany({ + const created = await createCompany({ name: name.input.value.trim(), business_registration_number: number.input.value.trim(), business_address: address.input.value.trim() || null, business_owner: owner.input.value.trim() || null, }); + const file = logo.input.files?.[0]; + if (file && created?.company_id) { + const form = new FormData(); + form.append("kind", "LOGO"); + form.append("label", `${name.input.value.trim()} 로고`); + form.append("file", file); + form.append("company_id", String(created.company_id)); + const assetId = await createCompanyAsset(form); + await setCompanyLogo(assetId, created.company_id); + } showToast(L("B01_Dashboard_Saved"), "success"); }, ); @@ -361,15 +426,33 @@ export function openFindCompanyModal(): void { ); } -export function openAddMemberModal(): void { +/** + * 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다** + * (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다. + */ +// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`). +// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것. +export function openAddMemberModal(onCreated?: (member: Member) => void): void { const email = createInputField({ label: L("B01_Dashboard_Field_MemberEmail"), type: "email", required: true, }); - openModal(L("B01_Dashboard_Modal_AddMember"), [email.root], async () => { - if (!email.input.value.trim()) return; - await addCompanyMember(email.input.value.trim()); - showToast(L("B01_Dashboard_Saved"), "success"); - }); + const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); + const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); + const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); + openModal( + L("B01_Dashboard_Modal_AddMember"), + [email.root, name.root, position.root, department.root], + async () => { + if (!email.input.value.trim()) return; + const created = await addCompanyMember(email.input.value.trim(), { + name: name.input.value.trim() || undefined, + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + if (created?.member) onCreated?.(created.member); + }, + ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 215d7ba4..66009c67 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -148,7 +148,7 @@ function buildPage(state: DashboardState): HTMLElement { createButton({ label: "+", onClick: () => openAddMemberModal() }), ]), section(L("B01_Dashboard_JoinRequests"), joinRequestTable(state.allJoinRequests, true), true), - section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies), true, [ + section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [ createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), diff --git a/db_management/014_company_logo.sql b/db_management/014_company_logo.sql new file mode 100644 index 00000000..8c34b99f --- /dev/null +++ b/db_management/014_company_logo.sql @@ -0,0 +1,20 @@ +-- 014_company_logo.sql +-- 표제란 사람·서명·로고 재배치 (2026-09-02 사용자 확정) +-- +-- ① 서명은 프로젝트가 아니라 **사람(계정)에 붙는다** — 이름이 들어가는 자리 +-- (과업책임자·분야별책임자·설계자)의 서명을 그 사람 계정에서 읽는다. +-- 표현 수단은 013 의 `company_assets` 그대로다: `kind='SIGNATURE'` + `user_id=<그 사람>`. +-- 그래서 **서명 쪽은 새 컬럼이 없다**. +-- ② 회사 로고는 **회사 등록 단계**에서 받고 이후 수정·변경한다 — 회사마다 대표 로고 1벌을 +-- 가리키는 자리가 필요해 이 컬럼을 만든다(`kind='LOGO'` + `user_id IS NULL`). +-- ③ 프로젝트가 고른 로고(`projects.logo_asset_id`)는 그대로 두고 **덮어쓰기**로 남긴다 — +-- 비어 있으면 회사 로고를 쓴다. +-- +-- `projects.signature_asset_id`(013)는 이제 읽지 않는다. 컬럼은 남겨 둔다 +-- (4환경 공유 DB 라 지우는 쪽이 위험하다 — 013 과 같은 판단). + +USE aislo_db; + +ALTER TABLE companies + ADD COLUMN IF NOT EXISTS logo_asset_id INT NULL + COMMENT '회사 대표 로고 (company_assets.id, kind=LOGO)'; -- 2.54.0 From dde40101bb4d919553916ef720d3843f6e9f3317 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:01:56 +0900 Subject: [PATCH 006/441] =?UTF-8?q?fix(B07):=20=ED=91=9C=EC=A0=9C=EB=9E=80?= =?UTF-8?q?=20=EB=A1=9C=EA=B3=A0=C2=B7=EC=84=9C=EB=AA=85=20=EC=9B=90?= =?UTF-8?q?=EC=B2=9C=EC=9D=84=20=EA=B3=84=EC=A0=95=C2=B7=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=20=EC=9E=90=EC=82=B0=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서명: `projects.signature_asset_id` 대신 설계자(`designer_user_id`, 없으면 소유자) 계정의 `company_assets` SIGNATURE 자산을 따라감 (여러 벌이면 최신 1건) - 로고: 프로젝트가 고른 것 우선, 없으면 `companies.logo_asset_id` (014) 로 폴백 - `projects.signature_asset_id` 는 컬럼만 남기고 더 읽지 않음 — B01 이 항상 null 저장 Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_Router.py | 23 ++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 49cc9d7e..0be4acc2 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -120,9 +120,13 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: 사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는 배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게. - 로고·서명은 프로젝트가 회사 공유 자산(`company_assets`, 013)에서 고른 것만 싣는다 — - 안 골랐거나 자산이 지워졌으면 그림째 빠진다. 011 의 `companies.logo_path` · - `users.signature_path` 는 더 읽지 않는다. + 로고·서명은 회사 공유 자산(`company_assets`, 013)에서 오되 **원천이 다르다** + (2026-09-02 사용자 확정, B01 인계). 서명은 프로젝트가 아니라 **사람 계정**에 붙으므로 + 설계자(`designer_user_id`, 없으면 소유자) 계정의 `SIGNATURE` 자산을 따라간다. 로고는 + 프로젝트가 고른 것이 우선이고, 안 골랐으면 **회사 등록 단계에서 받은 회사 로고** + (`companies.logo_asset_id`, 014)로 떨어진다. 둘 다 없거나 자산이 지워졌으면 그림째 + 빠진다. `projects.signature_asset_id` 는 컬럼만 남기고 더 읽지 않는다(B01 이 항상 + `null` 로 저장). 011 의 `companies.logo_path` · `users.signature_path` 도 안 읽는다. 아직 못 채우는 자리와 이유: - 사업량·연도기번은 사람이 넣는 값이다(B01 프로젝트 수정 화면). 비어 있으면 빈칸. @@ -145,9 +149,18 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: LEFT JOIN users pm ON pm.id = p.pm_user_id LEFT JOIN users lead ON lead.id = p.field_lead_user_id LEFT JOIN company_assets logo - ON logo.id = p.logo_asset_id AND logo.deleted_at IS NULL + ON logo.id = COALESCE(p.logo_asset_id, c.logo_asset_id) + AND logo.deleted_at IS NULL LEFT JOIN company_assets sig - ON sig.id = p.signature_asset_id AND sig.deleted_at IS NULL + ON sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = COALESCE(p.designer_user_id, p.user_id) + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) WHERE p.id = %s AND p.deleted_at IS NULL """, (str(project_id),), -- 2.54.0 From 94883733d5d4160a4ab13a2bacbd9e27ac36c2e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:08:55 +0900 Subject: [PATCH 007/441] auto: 2026-09-02 19:08 (EOMSANGDON-HOME) --- B07_DesignDetail/B07_DesignDetail_Router.py | 28 ++++++++- .../template_2dDrawing/00_template_A1.json | 58 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 0be4acc2..b47861b8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -120,6 +120,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: 사람 배정(과업책임자·분야별책임자·설계자)은 `projects`의 FK를 따라간다. 설계자는 배정이 없으면 프로젝트 소유자로 떨어진다 — 혼자 쓰는 계정에서도 칸이 차게. + **이름이 들어가는 자리는 서명도 받는다**(2026-09-02 사용자 확정) — 세 사람 모두 + 자기 계정의 `SIGNATURE` 자산을 따라가고, 안 올린 사람 자리는 그림째 빠진다. 로고·서명은 회사 공유 자산(`company_assets`, 013)에서 오되 **원천이 다르다** (2026-09-02 사용자 확정, B01 인계). 서명은 프로젝트가 아니라 **사람 계정**에 붙으므로 설계자(`designer_user_id`, 없으면 소유자) 계정의 `SIGNATURE` 자산을 따라간다. 로고는 @@ -141,7 +143,7 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: SELECT p.name, p.region, p.client_org, p.project_number, p.work_amount, p.design_date, c.name, logo.file_path, COALESCE(designer.name, owner.name), sig.file_path, - pm.name, lead.name + pm.name, lead.name, pm_sig.file_path, lead_sig.file_path FROM projects p LEFT JOIN companies c ON c.id = p.company_id LEFT JOIN users owner ON owner.id = p.user_id @@ -161,6 +163,26 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: ORDER BY s.id DESC LIMIT 1 ) + LEFT JOIN company_assets pm_sig + ON pm_sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = p.pm_user_id + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) + LEFT JOIN company_assets lead_sig + ON lead_sig.id = ( + SELECT s.id + FROM company_assets s + WHERE s.user_id = p.field_lead_user_id + AND s.kind = 'SIGNATURE' + AND s.deleted_at IS NULL + ORDER BY s.id DESC + LIMIT 1 + ) WHERE p.id = %s AND p.deleted_at IS NULL """, (str(project_id),), @@ -181,6 +203,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: signature_path, pm, lead, + pm_signature_path, + lead_signature_path, ) = row fields = { "공사명": name, @@ -196,6 +220,8 @@ async def _title_block_fields(project_id: UUID) -> dict[str, str]: "분야별책임자": lead, "회사로고": _asset_data_url(logo_path), "설계자서명": _asset_data_url(signature_path), + "과업책임자서명": _asset_data_url(pm_signature_path), + "분야별책임자서명": _asset_data_url(lead_signature_path), } return {key: str(value) for key, value in fields.items() if value} diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index c3c7fe98..5ccc8f9c 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1277,6 +1277,62 @@ ], "imageData": "{{설계자서명}}" } + }, + { + "id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 536.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 25.5 + }, + { + "x": 536.89, + "y": 25.5 + } + ], + "imageData": "{{과업책임자서명}}" + } + }, + { + "id": "d404c499-15dc-528f-a3bd-821cefa41961", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 587.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 25.5 + }, + { + "x": 587.91, + "y": 25.5 + } + ], + "imageData": "{{분야별책임자서명}}" + } } ], "layers": [ @@ -1293,4 +1349,4 @@ "isLocked": false } ] -} \ No newline at end of file +} -- 2.54.0 From 6e0c77d811c094bd6c9e592b753c95a12ab7bd36 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:09:43 +0900 Subject: [PATCH 008/441] =?UTF-8?q?docs(B07):=2094883733=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9=20=EC=84=A4=EB=AA=85=20=E2=80=94=20=EB=8F=84=EA=B0=81?= =?UTF-8?q?=20=ED=91=9C=EC=A0=9C=EB=9E=80=20=EC=84=9C=EB=AA=85=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=202=EA=B0=9C=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `94883733` 이 자동 폴백 커밋(`auto: …`)으로 묶여 나가 내용 설명이 빠짐. 이력 재작성 금지(CLAUDE.md 6장)라 amend 대신 이 빈 커밋으로 남김. 그 커밋의 실제 변경: - `resources/template_2dDrawing/00_template_A1.json` — Image 자리 2개 추가. `{{과업책임자서명}}`(x 536.89~578.89) · `{{분야별책임자서명}}`(x 587.91~629.91), 둘 다 y 17.5~25.5. 설계자 서명과 같은 폭 42·높이 8이고, 표제란 세로선 (531.87 / 583.91 / 633.91) 이 만드는 칸 안에서 좌우 여백 4로 가운데 배치. - `B07_DesignDetail_Router.py` `_title_block_fields` — 두 자리 값을 `pm_user_id`·`field_lead_user_id` 계정의 `company_assets` SIGNATURE 자산에서 읽음 (설계자와 같은 최신 1건 서브쿼리). 서명을 안 올린 사람 자리는 종전대로 엔티티째 빠짐. 자체검증: `frame-template` 응답이 Image 4건(회사로고+서명 3)으로 자리·좌표 확인, `tmp/tests/test_b07_title_block_signatures.py` 3건 신설, `pytest tmp/tests/ -q` 15 passed. Co-Authored-By: Claude Opus 5 (1M context) -- 2.54.0 From 3d0215f8d8e62407686f3031bb1a61d8a05d4a37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:19:57 +0900 Subject: [PATCH 009/441] =?UTF-8?q?feat(B07):=20=ED=91=9C=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=ED=9A=8C=EC=82=AC=20=EB=A1=9C=EA=B3=A0=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `00_template_cover.json` 에 Image 자리 추가 — `{{회사로고}}`, x 57.40~137.40 · y 64.06~104.06 (폭 80 · 높이 40) - 자리는 오른쪽 아래 `{{시행청}}`(기준점 784.35, 84.06)과 좌우로 마주 보게 잡음. 왼쪽 여백은 `{{연도기번}}`(x 57.4)과 맞추고 세로는 시행청 글자줄에 맞춤 - 값 공급은 도각과 같은 통로 — `build_cover_drawing` 이 이미 `_fill_placeholders` 를 태우므로 로고를 못 구하면 종전대로 엔티티째 빠짐 Co-Authored-By: Claude Opus 5 (1M context) --- .../template_2dDrawing/00_template_cover.json | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/resources/template_2dDrawing/00_template_cover.json b/resources/template_2dDrawing/00_template_cover.json index 4dff3543..901b9426 100644 --- a/resources/template_2dDrawing/00_template_cover.json +++ b/resources/template_2dDrawing/00_template_cover.json @@ -332,6 +332,34 @@ "fontFamily": "sans-serif" } } + }, + { + "id": "944f4ed1-a4e0-5ddf-b91f-3847984d2659", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.표지", + "shapeData": { + "points": [ + { + "x": 57.4, + "y": 64.06 + }, + { + "x": 137.4, + "y": 64.06 + }, + { + "x": 137.4, + "y": 104.06 + }, + { + "x": 57.4, + "y": 104.06 + } + ], + "imageData": "{{회사로고}}" + } } ], "layers": [ @@ -348,4 +376,4 @@ "isLocked": false } ] -} \ No newline at end of file +} -- 2.54.0 From ac983cf0e1290da9c679b882479d30c410b79c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:21:27 +0900 Subject: [PATCH 010/441] =?UTF-8?q?feat(B02):=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=ED=8F=BC=EC=97=90=20=ED=91=9C=EC=A0=9C=EB=9E=80=208=EC=B9=B8?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-02) — 프로젝트 등록에서도 수정 모달과 같은 항목 수신. - 시행청·연도기번·사업량·설계일자·과업책임자·분야별책임자·설계자·회사 로고 8칸 추가. 담당자는 select + 「신규 등록…」(관리자만), 로고는 회사 대표 로고가 기본값 - 담당자·자산 회사 경계 검사를 check_project_refs(company_id, data) 로 옮겨 B01 수정과 공용 - GET /admin/members 문턱을 require_company 로 — 일반 사용자도 담당자 선택 필요. _scope_company 가 남의 회사를 막고 쓰기는 관리자 그대로 - fix: road_type 스키마가 옛 값(branch·stream)을 받고 화면 선택지 work 를 막아 「작업임도」 등록이 422 로 떨어지던 것 수정 — ^(main|fire|work)$ - 검증(5174 실조작): 등록 칸 14개로 수정 모달과 동일, 등록된 행에 8칸 그대로 저장 (road_type=work, logo_asset_id=1, designer=3), 해당 프로젝트 표제란 12칸 전부 채워짐. pytest 151 passed (신규 test_b02_register_schema.py 8건) Co-Authored-By: Claude Opus 5 (1M context) --- .../B01_Dashboard_Repository_Members.py | 23 ++++ B01_Dashboard/B01_Dashboard_Router.py | 28 +---- .../B02_ProjRegister_Repository.py | 17 ++- B02_ProjRegister/B02_ProjRegister_Router.py | 17 +++ B02_ProjRegister/B02_ProjRegister_Schema.py | 16 ++- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 105 ++++++++++++++++++ 6 files changed, 181 insertions(+), 25 deletions(-) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 7a47abfe..0a7d9c1d 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -10,11 +10,13 @@ import secrets from typing import Any import aiomysql +from fastapi import HTTPException from common_util.common_util_auth import hash_password from config.config_db import get_db_pool from .B01_Dashboard_Repository import _role, get_dashboard_me +from .B01_Dashboard_Repository_Assets import list_company_assets async def list_company_members(company_id: int) -> list[dict[str, Any]]: @@ -109,3 +111,24 @@ async def set_company_logo(company_id: int, asset_id: int | None) -> bool: changed = cursor.rowcount > 0 await connection.commit() return changed + + +async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: + """담당자·로고·서명은 그 회사의 것만 물린다 (2026-09-02 사용자 확정). + + 프로젝트 수정(B01)과 등록(B02)이 같은 규칙을 써야 해서 저장소에 둔다. + """ + user_ids = { + data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k) + } + if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}: + raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.") + wanted = { + k: kind + for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE")) + if data.get(k) + } + if wanted: + kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)} + if any(kinds.get(data[k]) != kind for k, kind in wanted.items()): + raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.") diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 7dd7b2a0..a0ba6723 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -6,7 +6,7 @@ from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile -from common_util.common_util_auth import require_system_admin, verify_session +from common_util.common_util_auth import require_company, require_system_admin, verify_session from common_util.common_util_project_delete import hard_delete_project from common_util.common_util_storage import read_stored_asset from config.config_system import PROJECT_DELETE_HARD_ENABLED @@ -48,6 +48,7 @@ from .B01_Dashboard_Repository_Assets import ( ) from .B01_Dashboard_Repository_Members import ( add_company_member, + check_project_refs, list_company_members, remove_company_member, set_company_logo, @@ -106,25 +107,6 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An return asset -async def _check_project_refs(project: dict[str, Any], data: dict[str, Any]) -> None: - """담당자·로고·서명은 프로젝트 회사의 것만 물린다 (2026-09-02 사용자 확정).""" - company_id = int(project["company_id"]) - user_ids = { - data[k] for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data[k] - } - if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}: - raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.") - wanted = { - k: kind - for k, kind in (("logo_asset_id", "LOGO"), ("signature_asset_id", "SIGNATURE")) - if data[k] - } - if wanted: - kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)} - if any(kinds.get(data[k]) != kind for k, kind in wanted.items()): - raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.") - - def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True @@ -200,8 +182,10 @@ async def user_company_join( @router.get("/admin/members") async def admin_members( company_id: int | None = Query(None, gt=0), - session: dict[str, Any] = Depends(require_company_admin), + session: dict[str, Any] = Depends(require_company), ): + # 읽기는 회사 구성원 누구나 — B02 등록 화면에서 일반 사용자도 담당자를 골라야 한다 + # (2026-09-02 사용자 지시). 쓰기(POST·DELETE)는 관리자 그대로다. # company_id 는 시스템관리자가 남의 회사 프로젝트 담당자를 고를 때만 쓴다. return { "status": "success", @@ -280,7 +264,7 @@ async def dashboard_update_project( if not _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() - await _check_project_refs(project, data) + await check_project_refs(int(project["company_id"]), data) if not await update_project(project_id, data, int(session["user_id"])): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index da7989e9..06a1958f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -51,10 +51,12 @@ async def create_project( project_year: int, estimated_length_m: float | None, memo: str | None, + title_block: dict[str, Any] | None = None, ) -> dict[str, Any]: """신규 프로젝트를 DB에 저장하고 워크플로우 저장소를 초기화한다.""" project_id = str(uuid4()) + fields = title_block or {} storage_path, project_root = _build_project_storage(company_id, user_id, project_id) now = datetime.utcnow() @@ -67,9 +69,12 @@ async def create_project( INSERT INTO projects ( id, user_id, company_id, name, region, road_type, project_year, estimated_length_m, memo, status, - crs_epsg, storage_path, created_at, updated_at + crs_epsg, storage_path, created_at, updated_at, + client_org, project_number, work_amount, design_date, + pm_user_id, field_lead_user_id, designer_user_id, logo_asset_id ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'NEW', 5178, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s) """, ( project_id, @@ -84,6 +89,14 @@ async def create_project( storage_path, now, now, + fields.get("client_org"), + fields.get("project_number"), + fields.get("work_amount"), + fields.get("design_date"), + fields.get("pm_user_id"), + fields.get("field_lead_user_id"), + fields.get("designer_user_id"), + fields.get("logo_asset_id"), ), ) # 워크플로우 단계별 상태 초기화 시드 diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f053b985..ec160b60 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -4,6 +4,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException +from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs from common_util.common_util_auth import require_company from .B02_ProjRegister_Repository import create_project @@ -23,6 +24,21 @@ async def post_project( if company_id is None: raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + title_block = payload.model_dump( + include={ + "client_org", + "project_number", + "work_amount", + "design_date", + "pm_user_id", + "field_lead_user_id", + "designer_user_id", + "logo_asset_id", + } + ) + # 담당자·로고는 같은 회사 것만 (B01 프로젝트 수정과 같은 규칙). + await check_project_refs(int(company_id), title_block) + try: result = await create_project( user_id=int(session["user_id"]), @@ -33,6 +49,7 @@ async def post_project( project_year=payload.project_year, estimated_length_m=payload.estimated_length_m, memo=payload.memo.strip() if payload.memo else None, + title_block=title_block, ) return CreateProjectResponse(**result) except ValueError as exc: diff --git a/B02_ProjRegister/B02_ProjRegister_Schema.py b/B02_ProjRegister/B02_ProjRegister_Schema.py index 6b21c748..362ace0d 100644 --- a/B02_ProjRegister/B02_ProjRegister_Schema.py +++ b/B02_ProjRegister/B02_ProjRegister_Schema.py @@ -1,5 +1,7 @@ """B02_ProjRegister 요청/응답 스키마.""" +from datetime import date + from pydantic import BaseModel, Field @@ -8,10 +10,22 @@ class CreateProjectRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) region: str = Field(..., min_length=1, max_length=100) - road_type: str = Field(..., pattern="^(main|branch|fire|stream)$") + # 화면 선택지와 같은 3종 — 간선·산불진화·작업임도 (지선임도 폐지, 계류보전은 사방사업). + # 옛 값(branch·stream)을 받고 work 를 막고 있어 「작업임도」 등록이 422 로 떨어졌다. + road_type: str = Field(..., pattern="^(main|fire|work)$") project_year: int = Field(..., ge=2000, le=2100) estimated_length_m: float | None = Field(default=None, ge=0) memo: str | None = Field(default=None, max_length=1000) + # 도면 표제란·표지 값 — 등록 때부터 받는다 (2026-09-02 사용자 지시). + # 프로젝트 수정 모달(B01)과 같은 칸이며, 비워 두면 도면에 빈칸으로 나간다. + client_org: str | None = Field(default=None, max_length=255) + project_number: str | None = Field(default=None, max_length=100) + work_amount: str | None = Field(default=None, max_length=100) + design_date: date | None = None + pm_user_id: int | None = Field(default=None, gt=0) + field_lead_user_id: int | None = Field(default=None, gt=0) + designer_user_id: int | None = Field(default=None, gt=0) + logo_asset_id: int | None = Field(default=None, gt=0) class CreateProjectResponse(BaseModel): diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 7aa195ae..cc697a71 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -20,6 +20,14 @@ import { showToast, } from "@ui/ui_template_elements"; import { isBlank } from "@util/common_util_validate"; +import { + fetchCompanyAssets, + fetchCompanyMembers, + fetchDashboardMe, + fetchUserCompany, +} from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker"; +import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -88,6 +96,84 @@ export function renderB02ProjRegister(root: HTMLElement): void { type: "text", }); + // 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다 + // (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다. + const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" }); + const projectNumberField = createInputField({ + label: "연도·기번 (표지)", + placeholder: "예: 2026년 간선임도(기번3-울진.대흥)", + }); + const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" }); + const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); + + const NEW_MEMBER = "__new__"; + const person = (label: string) => + createSelectField({ label, options: [{ value: "", text: "(미지정)" }] }); + const pmField = person("과업책임자 (도면 표제란)"); + const fieldLeadField = person("분야별책임자 (도면 표제란)"); + const designerField = person("설계자 (도면 표제란)"); + const personSelects = [pmField.select, fieldLeadField.select, designerField.select]; + // 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다. + const logoSlot = document.createElement("div"); + let logoValue: () => number | null = () => null; + + void (async () => { + const [me, company] = await Promise.all([ + fetchDashboardMe(), + fetchUserCompany().catch(() => null), + ]); + const [members, assets] = await Promise.all([ + fetchCompanyMembers().catch(() => []), + fetchCompanyAssets().catch(() => []), + ]); + const memberText = (member: (typeof members)[number]) => + member.position ? `${member.name} (${member.position})` : member.name; + const addOption = (select: HTMLSelectElement, value: string, text: string) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = text; + select.append(option); + }; + for (const select of personSelects) { + for (const member of members) addOption(select, String(member.id), memberText(member)); + // 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다. + if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…"); + } + for (const select of personSelects) { + let last = select.value; + select.addEventListener("change", () => { + if (select.value !== NEW_MEMBER) { + last = select.value; + return; + } + select.value = last; + openAddMemberModal((member) => { + for (const other of personSelects) { + const option = document.createElement("option"); + option.value = String(member.id); + option.textContent = memberText(member); + other.insertBefore(option, other.options[other.options.length - 1]); + } + select.value = String(member.id); + last = select.value; + }); + }); + } + if (company) { + // 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다. + const logo = createAssetField( + "회사 로고 (도면 표제란)", + "LOGO", + assets, + company.logo_asset_id ?? null, + company.id, + me, + ); + logoValue = logo.value; + logoSlot.append(logo.root); + } + })(); + const submitBtn = createButton({ label: L("B02_Proj_Submit"), variant: "filled", @@ -95,6 +181,9 @@ export function renderB02ProjRegister(root: HTMLElement): void { }); submitBtn.classList.add("b02-proj__submit"); + const idOrNull = (select: HTMLSelectElement): number | null => + select.value && select.value !== NEW_MEMBER ? Number(select.value) : null; + async function onB02_Proj_Submit_Click(): Promise { nameField.setError(); regionField.setError(); @@ -138,6 +227,14 @@ export function renderB02ProjRegister(root: HTMLElement): void { project_year: projectYear, estimated_length_m: estimatedLength, memo: memoField.input.value.trim() || null, + client_org: clientOrgField.input.value.trim() || null, + project_number: projectNumberField.input.value.trim() || null, + work_amount: workAmountField.input.value.trim() || null, + design_date: designDateField.input.value || null, + pm_user_id: idOrNull(pmField.select), + field_lead_user_id: idOrNull(fieldLeadField.select), + designer_user_id: idOrNull(designerField.select), + logo_asset_id: logoValue(), }), }); @@ -173,6 +270,14 @@ export function renderB02ProjRegister(root: HTMLElement): void { yearField.root, lengthField.root, memoField.root, + clientOrgField.root, + projectNumberField.root, + workAmountField.root, + designDateField.root, + pmField.root, + fieldLeadField.root, + designerField.root, + logoSlot, ); const card = createCard({ body: [grid, submitBtn], raised: true }); -- 2.54.0 From 3651ad987576c13ef9c72159a83ba261f5e8e471 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 19:30:16 +0900 Subject: [PATCH 011/441] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=A0=20=EC=A0=84=EC=B2=B4=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=ED=8F=B4=EB=A6=AC=EB=9D=BC=EC=9D=B8=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20=EC=A7=81=EC=84=A0=ED=99=94=C2=B7=EC=89=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=C2=B7undo/redo=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로 코딩까지만 진행). 1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설. 모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만 (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`). basis `ground_polyline`. 관 정착 선형은 폴백으로 내림. 2. 구간 쉬프트(⬆⬇) 삭제. 3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한 직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성. 4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택, 최외곽 라운드 중심 기준 상·하 평행이동. 5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정). 6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`). 버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z. 7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지). 8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체. 9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치. 700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물 짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄). 검증: tsc --noEmit 오류 0, ruff format/check 통과, pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_Chain.py | 2 +- B05_Profile/B05_Profile_Engine_Grade.py | 4 +- .../B05_Profile_Engine_Grade_Alignment.py | 22 +- .../B05_Profile_Engine_Grade_Profile.py | 94 ++++++- B05_Profile/B05_Profile_Engine_Sections.py | 19 +- B05_Profile/B05_Profile_Schema.py | 3 +- B05_Profile/B05_Profile_UI_Panel.ts | 3 + .../B05_Profile_UI_Profile_Alignment.ts | 18 +- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 41 +-- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 106 +------ B05_Profile/B05_Profile_UI_Profile_History.ts | 124 +++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 134 ++++----- .../B05_Profile_UI_Profile_Panel_Tools.ts | 159 +++++++++++ B05_Profile/B05_Profile_UI_Profile_Preview.ts | 79 ++++++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 37 +-- .../B05_Profile_UI_Profile_Straighten.ts | 263 ++++++++++++++++++ .../B05_Profile_UI_Profile_Structures.ts | 46 ++- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 180 ++++++++++++ B05_Profile/B05_Profile_UI_Style.css | 42 ++- B06_Section/B06_Section_UI_Longitudinal.ts | 2 + 20 files changed, 1133 insertions(+), 245 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_History.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Preview.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Straighten.ts create mode 100644 B05_Profile/B05_Profile_UI_Profile_Tools.ts diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 39bd3125..e1fb177d 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -382,7 +382,7 @@ async def run_redesign_chain( long_sample_interval_m=params.get("long_sample_interval_m"), **{key: options.get(key) for key in ("grade_class",) if options.get(key)}, paved=bool(options.get("paved", False)), - terrain_type=str(options.get("terrain_type") or "normal"), + terrain_type=str(options.get("terrain_type") or "special"), main_direction=str(options.get("main_direction") or "auto"), min_curve_radius_m=options.get("min_curve_radius_m"), max_uphill_grade=options.get("max_uphill_grade"), diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index cd4e1b6f..c7574a34 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -55,7 +55,9 @@ class GradeDesignOptions: min_tangent_length_m: float vertical_curve_skip_delta_pct: float design_speed_kph: int - terrain_type: str = "normal" + # 기본 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이다. + # 잘못된 값이 들어오면 아래 판정 함수들이 "normal" 로 되눌러 법정 상한을 낮게 잡는다. + terrain_type: str = "special" paved: bool = False main_direction: str = "auto" balance_segment_length_m: float | None = None diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index b76f9be8..005d5a8a 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -187,6 +187,8 @@ def build_curves( pvi_z: np.ndarray, policy: AlignmentPolicy, curve_radii: dict[str, float] | None = None, + *, + only_explicit: bool = False, ) -> tuple[list[dict[str, Any]], list[str]]: """각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다. @@ -195,6 +197,10 @@ def build_curves( 곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해 좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다). + + `only_explicit=True` 면 **사용자가 R을 지정한 변화점에만** 곡선을 넣는다. 전체 측점 + 폴리라인(2026-09-02 사용자 확정)은 모든 측점이 변화점이라 기본 곡선을 다 넣으면 + 계획고가 지반고에서 떠 버린다 — 라운드는 [직선화]·틸팅으로 사용자가 만들 때만 생긴다. """ overrides = curve_radii or {} spans = pvi_s[1:] - pvi_s[:-1] @@ -209,6 +215,8 @@ def build_curves( key = chainage_key(chainage) if abs(delta) < 1e-9: continue + if only_explicit and overrides.get(key) is None: + continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. # 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을 # 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능). @@ -370,8 +378,13 @@ def build_alignment( stations: list[dict[str, Any]], policy: AlignmentPolicy, edits: dict[str, Any] | None = None, + only_explicit_curves: bool = False, ) -> dict[str, Any]: - """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다.""" + """자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다. + + `only_explicit_curves=True` 는 전체 측점 폴리라인용 — 사용자가 R을 지정한 변화점에만 + 종단곡선을 넣는다(`build_curves(only_explicit=...)` 와 같은 뜻). + """ edits = edits or {} station_offsets = { str(key): float(value) @@ -385,7 +398,9 @@ def build_alignment( } pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets) - curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii) + curves, warnings = build_curves( + pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves + ) segments = _segments(pvi_s, pvi_z) # 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면 # 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를 @@ -452,6 +467,9 @@ def build_alignment( return { "schema_version": ALIGNMENT_SCHEMA_VERSION, "policy": policy.as_dict(), + # 화면 사본(`B05_Profile_UI_Profile_Alignment.ts`)이 같은 규칙으로 다시 그리려면 + # 이 값이 저장본에 남아 있어야 한다(2026-09-02 전체 측점 폴리라인). + "only_explicit_curves": bool(only_explicit_curves), "base_pvi": [ {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)} for s, z in zip(base_s, base_z) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index 87cdae81..c38a6a8f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -5,8 +5,12 @@ [[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어 `design_profiles` 배열에 넣을 계획선 한 벌을 만든다. -세 진입점이 있다. - - `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을 +네 진입점이 있다. + - `design_ground_following_profile()` : **1차(기본, 2026-09-02 사용자 확정)**. 모든 측점을 + 변화점으로 삼아 계획고를 **원지반고 그대로** 두는 폴리라인이다. 종단곡선은 사용자가 + [직선화]·틸팅으로 만들 때만 생긴다. 관 측점만 정착하던 옛 방식은 관 사이가 길면 골을 + 성토로, 마루를 절토로 메워 최대 성토 +9.36m(용화 실측)가 남았다. + - `design_pipe_anchored_profile()` : 관 정착 선형(옛 1차). 배수유역도가 산출한 배관 배치 측점을 변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 → 배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다. @@ -49,6 +53,8 @@ ALIGNMENT_PROFILE_ID = "design_grade_line" # 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02). PIPE_ANCHORED_BASIS = "pipe_anchored" ALIGNMENT_BASIS = "station_alignment" +# 전체 측점 폴리라인(2026-09-02 사용자 확정) — 계획고 = 지반고. +GROUND_POLYLINE_BASIS = "ground_polyline" def infer_station_interval(stations: list[dict[str, Any]]) -> float: @@ -138,6 +144,83 @@ def _clearance_at( return best +def design_ground_following_profile( + longitudinal: dict[str, Any], + options: GradeDesignOptions, + *, + station_interval_m: float | None = None, + edits: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + """전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선. + + 규칙(2026-09-02 사용자 확정): + - **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다. + - **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을 + 틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다 + (`build_alignment(only_explicit_curves=True)`). + - 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다 + (기존 정책과 같다). + + 시·종점 오프셋(`start/end_elevation_offset_m`)은 그대로 반영한다. 기본값 0이라 + 평소에는 양끝도 지반고다. + """ + options.validate() + chainage, ground = ground_profile(longitudinal) + total = float(chainage[-1]) + if total <= 0: + raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.") + + stations = list(longitudinal.get("stations") or []) + interval = float(station_interval_m or 0) or infer_station_interval(stations) + policy = AlignmentPolicy.from_config( + station_interval_m=interval, + max_grade_pct=options.max_grade_pct, + curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, + paved=options.paved, + ) + + warnings = list(options.warnings) + # 측점 목록이 곧 변화점 목록이다. 측점이 비었거나 양끝이 빠져 있으면 종단 격자를 쓴다. + node_s = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] + node_s = sorted({round(value, 3) for value in node_s if 0.0 <= value <= total}) + if len(node_s) < 2: + node_s = [round(float(value), 3) for value in chainage] + if node_s[0] > 0.0: + node_s.insert(0, 0.0) + if node_s[-1] < total: + node_s.append(round(total, 3)) + + base_s = np.array(node_s, dtype=np.float64) + base_z = np.interp(base_s, chainage, ground) + base_z[0] += options.start_elevation_offset_m + base_z[-1] += options.end_elevation_offset_m + + rise = float(base_z[-1] - base_z[0]) + direction, note = ( + detect_main_direction(ground, rise) + if options.main_direction == "auto" + else (options.main_direction, None) + ) + if note: + warnings.append(note) + + alignment = build_alignment( + base_s=base_s, + base_z=base_z, + chainage=chainage, + ground=ground, + stations=stations, + policy=policy, + edits=dict(edits or {}), + only_explicit_curves=True, + ) + warnings.extend(alignment["warnings"]) + balanced = bool(alignment["balance"]["within_tolerance"]) + return alignment, _profile_entry( + alignment, options, direction, balanced, warnings, GROUND_POLYLINE_BASIS + ) + + def design_pipe_anchored_profile( longitudinal: dict[str, Any], options: GradeDesignOptions, @@ -365,6 +448,11 @@ def rebuild_alignment_profile( base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64) policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) + previous = (longitudinal.get("design_profiles") or [{}])[0] + # 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야 + # 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02). + only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS + alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -373,8 +461,8 @@ def rebuild_alignment_profile( stations=list(longitudinal.get("stations") or []), policy=policy, edits=edits, + only_explicit_curves=only_explicit, ) - previous = (longitudinal.get("design_profiles") or [{}])[0] criteria = previous.get("criteria") or {} options = GradeDesignOptions( max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct), diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 9596f81b..9bf92f92 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -15,6 +15,7 @@ from typing import Any from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, design_grade_line from B05_Profile.B05_Profile_Engine_Grade_Profile import ( design_alignment_profile, + design_ground_following_profile, design_pipe_anchored_profile, ) from B05_Profile.B05_Profile_Engine_Sections_Core import ( @@ -214,18 +215,24 @@ def _append_design_profiles( ) -> dict[str, Any] | None: """종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다. - 1순위는 **배관 정착 선형** — 배수유역도의 배관 배치 측점마다 계획선이 지면선과 - 만나도록 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정). 배관이 없거나 - 산출이 불가하면 2순위로 기존 지반 추종 직선 분할 선형(`design_alignment_profile`), - 그마저 실패하면 구 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 - 종횡단 생성 자체는 유지한다. + 1순위는 **전체 측점 폴리라인**(`design_ground_following_profile`) — 모든 측점의 + 계획고를 원지반고에 맞추고 종단곡선은 넣지 않는다(2026-09-02 사용자 확정). + 실패하면 2순위 배관 정착 선형(`design_pipe_anchored_profile`, 옛 1차), + 3순위 지반 추종 직선 분할 선형(`design_alignment_profile`), 그마저 실패하면 구 + 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 종횡단 생성 자체는 유지한다. """ longitudinal.setdefault("design_profiles", []) if grade_options is None: return None alignment = None profile = None - if pipe_chainages: + try: + alignment, profile = design_ground_following_profile( + longitudinal, grade_options, station_interval_m=station_interval_m + ) + except (ValueError, KeyError, ArithmeticError): + logger.exception("B05 전체 측점 폴리라인 산출 실패 — 배관 정착 선형으로 대체") + if profile is None and pipe_chainages: try: alignment, profile = design_pipe_anchored_profile( longitudinal, diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 2c89dc1a..0ccfff61 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -91,7 +91,8 @@ class RouteSolveRequest(BaseModel): long_sample_interval_m: float | None = Field(default=None, gt=0) # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. - terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") + # 기본 특수지형(2026-09-02 사용자 지시) — B05 좌측 패널 기본 선택과 같은 값이다. + terrain_type: str = Field(default="special", description="지형 구분 (normal/special)") # 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이 # 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정). # 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다. diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 7e58e2c9..21ecbd2e 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -398,6 +398,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ], }); const terrainType = terrainField.select; + // 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라 + // 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다. + terrainType.value = "special"; // 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로 // 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다. // 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관 diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 2c84fa49..929494dc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -112,6 +112,9 @@ export interface AlignmentEdits { export interface ProfileAlignment { schema_version: number; policy: AlignmentPolicy; + /** 전체 측점 폴리라인이면 true — 사용자가 R을 지정한 변화점에만 라운드를 넣는다 + * (2026-09-02 사용자 확정, 백엔드 `build_alignment(only_explicit_curves=)` 와 같은 값). */ + only_explicit_curves?: boolean; base_pvi: AlignmentNode[]; edits: AlignmentEdits; pvi: AlignmentPvi[]; @@ -127,6 +130,8 @@ export interface ProfileAlignment { /** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */ export interface AlignmentBase { policy: AlignmentPolicy; + /** 라운드를 사용자가 지정한 변화점에만 넣을지 — 저장본에서 그대로 물려받는다. */ + onlyExplicitCurves: boolean; basePvi: AlignmentNode[]; chainage: number[]; ground: number[]; @@ -162,6 +167,7 @@ function interpolate(xs: number[], ys: number[], value: number): number { export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { return { policy: alignment.policy, + onlyExplicitCurves: alignment.only_explicit_curves === true, basePvi: alignment.base_pvi.map((node) => ({ ...node })), chainage: alignment.samples.map((sample) => sample.chainage_m), ground: alignment.samples.map((sample) => sample.ground_elevation_m), @@ -207,6 +213,7 @@ function buildCurves( policy: AlignmentPolicy, curveRadii: Record, warnings: string[], + onlyExplicit = false, ): WorkingCurve[] { const curves: WorkingCurve[] = []; const skipDelta = policy.curve_skip_delta_pct / 100; @@ -225,6 +232,8 @@ function buildCurves( // 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다. const requested = curveRadii[key]; const hasRequested = Number.isFinite(requested) && requested > 0; + // 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다. + if (onlyExplicit && !hasRequested) continue; const fallbackLength = Number.isFinite(policy.default_curve_length_m) ? (policy.default_curve_length_m as number) : policy.default_curve_radius_m * Math.abs(delta); @@ -309,7 +318,13 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); const pviZ = nodes.map((node) => node.elevation_m); - const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings); + const curves = buildCurves( + nodes, + base.policy, + edits.curve_radii, + warnings, + base.onlyExplicitCurves === true, + ); const segments: AlignmentSegment[] = []; for (let index = 0; index < nodes.length - 1; index += 1) { @@ -411,6 +426,7 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof return { schema_version: 1, policy: base.policy, + only_explicit_curves: base.onlyExplicitCurves === true, base_pvi: base.basePvi, edits, pvi: pviRows, diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 92ab128b..5f7f7c68 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -15,7 +15,7 @@ * ========================================================================== */ import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; -import { minCoverWarningText, type MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; +import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -30,6 +30,8 @@ export interface BalanceBarParams { hasIrregularStations: boolean; /** 저장되지 않은 편집이 있는지. */ dirty: boolean; + /** 요약줄 맨 앞에 놓는 도구줄(되돌리기·직선화·쉬프트·틸팅) — 2026-09-02. */ + tools?: HTMLElement; /** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */ minCoverViolations?: MinCoverViolation[]; /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ @@ -38,6 +40,8 @@ export interface BalanceBarParams { export function renderBalanceBar(params: BalanceBarParams): void { params.balanceBar.replaceChildren(); + // 도구줄은 계획선이 없을 때도 둔다 — 되돌리기는 계획선 밖 조작(구조물 등)도 되돌린다. + if (params.tools) params.balanceBar.append(params.tools); if (!params.alignment) { if (params.legacyAlignment) { const note = document.createElement("span"); @@ -74,10 +78,8 @@ export function renderBalanceBar(params: BalanceBarParams): void { ]; // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); - // 횡단배수 최소 계획고(2026-08-23 사용자 지시) — 관경·구체높이 + 토피 0.5m를 - // 밑도는 측점이 있으면 경고한다. 계획선을 대신 올려 주지는 않는다(사용자 판단). - const coverWarning = minCoverWarningText(params.minCoverViolations ?? []); - if (coverWarning) entries.push(["횡단배수 최소고", coverWarning, "over"]); + // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 + // `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다. const editedCount = Object.keys(alignment.edits.station_offsets).length; if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { @@ -88,14 +90,6 @@ export function renderBalanceBar(params: BalanceBarParams): void { item.append(caption, document.createTextNode(value)); // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). - if (label === "횡단배수 최소고" && params.minCoverViolations?.length) { - item.title = params.minCoverViolations - .map( - (entry) => - `${entry.chainage_m.toFixed(1)}m ${entry.label}: 계획고 ${entry.planned_m.toFixed(2)} < 최소 ${entry.required_m.toFixed(2)} (부족 ${entry.shortfall_m.toFixed(2)}m)`, - ) - .join("\n"); - } if (label === "최대 기울기" && violations.length) { item.title = violations .map( @@ -106,25 +100,8 @@ export function renderBalanceBar(params: BalanceBarParams): void { } params.balanceBar.append(item); }); - if (params.edited || params.hasIrregularStations) { - const reset = document.createElement("button"); - reset.type = "button"; - reset.className = "b05-route-profile__balance-reset"; - // 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규 - // 측점). [저장]을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장 - // 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산 - // 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정). - // 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다. - reset.textContent = "편집 되돌리기"; - reset.title = - "화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" + - "이미 저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" + - "최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요."; - reset.addEventListener("click", () => { - params.onResetAll(); - }); - params.balanceBar.append(reset); - } + // [편집 되돌리기] 버튼은 2026-09-02 사용자 지시로 삭제했다 — 한 단계씩 되돌리는 + // undo/redo가 대신하며, 최초 자동 계산 상태 복귀는 좌측 하단 [초기화]가 맡는다. if (params.dirty) { const badge = document.createElement("span"); badge.className = "b05-route-profile__balance-item is-unsaved"; diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index d762e5b5..e6892e8e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -7,19 +7,15 @@ * * 버튼 구성 (평시 투명, 패널 hover 시 노출): * - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다. - * - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동). - * - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다. * - * 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 - * 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고 - * 구간 버튼만 옆으로 비킨다. + * 구간 ⇧⇩(평행이동)과 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — 구간 조작은 + * [직선화]+[쉬프트]가, 되돌리기는 undo/redo가 대신한다. + * + * 측점 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 측점 + * 라벨에 가려 보이지 않기 때문이다. * ========================================================================== */ -import type { - AlignmentEdits, - AlignmentSegment, - ProfileAlignment, -} from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; @@ -173,15 +169,9 @@ export interface EditOverlayOptions { width: number; x: (chainageM: number) => number; step: number; - /** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */ + /** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼ 버튼을 단다. */ irregularStations?: Array<{ chainage_m: number }>; onStation: (chainageM: number, delta: number) => void; - onSegment: (segment: AlignmentSegment, delta: number) => void; - onResetStation: (chainageM: number) => void; - /** 구간 평행이동 편집을 자동 선형으로 원복(양 끝 오프셋 삭제). */ - onResetSegment: (segment: AlignmentSegment) => void; - /** 쉬프트 가능 구간 판정 — false면 그 구간의 ⬆⬇를 만들지 않는다(힌지 부족). */ - canShift?: (segment: AlignmentSegment) => boolean; } function overlayButton( @@ -235,8 +225,7 @@ function planElevationAtSample(alignment: ProfileAlignment, chainageM: number): /** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { - const { alignment, width, x, step, onStation, onSegment, onResetStation, onResetSegment } = - options; + const { alignment, width, x, step, onStation } = options; const irregular = options.irregularStations ?? []; const layer = document.createElement("div"); layer.className = "b05-profile-edit"; @@ -281,31 +270,6 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { }; })(), ); - const stationXs = stationPlacements.map((entry) => entry.left); - - /** 이미 자리를 잡은 구간 버튼 x — 구간끼리도 겹치지 않게 기억해 둔다. */ - const segmentXs: number[] = []; - - /** - * 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다. - * 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고 - * 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다). - * - * 한 번만 밀면 밀어낸 자리에 또 다른 측점·구간 버튼이 있을 때 그대로 겹친다 - * (2026-08-17 사용자 보고). 빈자리가 나올 때까지 반복해서 밀어낸다. - */ - function avoidStations(center: number): number { - let placed = center; - for (let guard = 0; guard < 40; guard += 1) { - const blocking = [...stationXs, ...segmentXs] - .filter((px) => Math.abs(px - placed) < BUTTON_CLEARANCE_PX) - .sort((left, right) => right - left)[0]; - if (blocking === undefined) break; - placed = blocking + BUTTON_CLEARANCE_PX; - } - segmentXs.push(placed); - return placed; - } // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 // `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다. // left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다. @@ -327,60 +291,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement { down.style.left = `${left - BUTTON_HALF_PX}px`; layer.append(up, down); - if (!isEdited) return; - const offset = alignment.edits.station_offsets[chainageKey(chainageM)]; - const reset = overlayButton( - repeater, - "is-reset", - "↺", - `${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`, - () => onResetStation(chainageM), - ); - reset.style.left = `${left - BUTTON_HALF_PX}px`; - layer.append(reset); + // 측점 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — undo/redo 로 대체한다. + void isEdited; } stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left)); - alignment.segments.forEach((segment) => { - const left = x(segment.from_m); - const right = x(segment.to_m); - if (right - left < 36) return; - // 힌지(안쪽 미틸트 측점 2개)를 못 만드는 구간은 쉬프트 대상이 아니다(2026-08-23). - if (options.canShift && !options.canShift(segment)) return; - const center = avoidStations((left + right) / 2); - const label = - `구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` + - `(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`; - // 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라 - // 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다. - const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label} — ${step}m 올림`, () => - onSegment(segment, step), - ); - up.style.left = `${center - BUTTON_HALF_PX}px`; - const down = overlayButton( - repeater, - "is-segment is-down", - "⬇︎", - `${label} — ${step}m 내림`, - () => onSegment(segment, -step), - ); - down.style.left = `${center - BUTTON_HALF_PX}px`; - layer.append(up, down); - - // 구간 양 끝 중 하나라도 편집됐으면 원복 ↺ 노출(측점 ↺와 동일 스타일·연산). - if (edited.has(chainageKey(segment.from_m)) || edited.has(chainageKey(segment.to_m))) { - const reset = overlayButton( - repeater, - "is-segment is-reset", - "↺", - `${label} — 자동 선형으로 원복`, - () => onResetSegment(segment), - ); - reset.style.left = `${center - BUTTON_HALF_PX}px`; - layer.append(reset); - } - }); + // 구간 평행이동(⬆⬇)과 구간 원복 ↺ 도 같은 지시로 삭제했다. 구간 단위 조작은 + // [직선화]로 만든 직선을 [쉬프트]로 옮기는 흐름이 대신한다. return layer; } diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts new file mode 100644 index 00000000..701ae660 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_History.ts + * B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷. + * + * 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이 + * 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측 + * 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가 + * 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는 + * **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다. + * + * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 + * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). + * ========================================================================== */ + +/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ +const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; + +/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ +function isLayoutKey(key: string): boolean { + return ( + key.includes("collapsed") || + key.includes("width") || + key.includes("height") || + key.includes("open") || + key.includes("visible") + ); +} + +function isDataKey(key: string): boolean { + return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); +} + +/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ +export type HistorySnapshot = Record; + +function takeSnapshot(): HistorySnapshot { + const snapshot: HistorySnapshot = {}; + try { + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index); + if (!key || !isDataKey(key)) continue; + const value = sessionStorage.getItem(key); + if (value !== null) snapshot[key] = value; + } + } catch { + // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. + } + return snapshot; +} + +function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + return leftKeys.every((key) => left[key] === right[key]); +} + +/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ +function restoreSnapshot(snapshot: HistorySnapshot): void { + try { + const existing: string[] = []; + for (let index = 0; index < sessionStorage.length; index += 1) { + const key = sessionStorage.key(index); + if (key && isDataKey(key)) existing.push(key); + } + existing.forEach((key) => { + if (!(key in snapshot)) sessionStorage.removeItem(key); + }); + Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value)); + } catch { + // 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다. + } +} + +export interface ProfileHistory { + /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ + record(): void; + undo(): boolean; + redo(): boolean; + canUndo(): boolean; + canRedo(): boolean; +} + +/** + * 되돌리기 이력을 만든다. + * + * `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로 + * 여기서 각 패널이 자기 값을 다시 읽어 그린다. + */ +export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory { + const stack: HistorySnapshot[] = [takeSnapshot()]; + let cursor = 0; + /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ + let restoring = false; + + function apply(index: number): boolean { + if (index < 0 || index >= stack.length) return false; + cursor = index; + restoring = true; + restoreSnapshot(stack[cursor]); + try { + onRestore(); + } finally { + restoring = false; + } + return true; + } + + return { + record() { + if (restoring) return; + const snapshot = takeSnapshot(); + if (sameSnapshot(snapshot, stack[cursor])) return; + stack.splice(cursor + 1); + stack.push(snapshot); + if (stack.length > limit) stack.shift(); + cursor = stack.length - 1; + }, + undo: () => apply(cursor - 1), + redo: () => apply(cursor + 1), + canUndo: () => cursor > 0, + canRedo: () => cursor < stack.length - 1, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index a9c1e43d..01e5ea34 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -15,11 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - structureAnchorM, - type StructureInstance, - type StructureType, -} from "./B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { createProfileTableOverlay, TABLE_OVERLAY_MIN_HEIGHT, @@ -28,7 +24,6 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; -import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import { findMinCoverViolations, @@ -58,6 +53,12 @@ import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance"; import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights"; import { renderProfile } from "./B05_Profile_UI_Profile_Render"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview"; +import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools"; +import { + stationIdAtStructure as stationIdAtStructureOf, + structureIdAtStation as structureIdAtStationOf, +} from "./B05_Profile_UI_Profile_Structures"; import "../B06_Section/B06_Section_UI_Style.css"; // SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때 // 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인. @@ -300,11 +301,10 @@ export function createRouteProfilePanel( let base: AlignmentBase | null = null; let alignment: ProfileAlignment | null = null; let store = createProfileEditStore(null, emptyEdits(), () => rebuild()); + /** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */ + let savedEdits: AlignmentEdits = emptyEdits(); let resizeTimer = 0; let redrawPending = false; - /** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */ - let crossPreviewTimer = 0; - let crossPreviewSeq = 0; let lastWidth = 0; let lastHeight = 0; @@ -312,33 +312,11 @@ export function createRouteProfilePanel( * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). * 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다. */ - /** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ - const SAME_CHAINAGE_M = 0.51; - - /** 측점선 id → 알약(구조물) id. 같은 누가거리의 구조물이 없으면 null. */ - function structureIdAtStation(stationId: string | null): string | null { - if (stationId === null) return null; - const prefix = irregularStationId(""); - if (!stationId.startsWith(prefix)) return null; - const station = irregularStations.find((entry) => irregularStationId(entry.id) === stationId); - if (!station) return null; - const hit = structures.find( - (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, - ); - return hit?.structure_id ?? null; - } - - /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ - function stationIdAtStructure(structureId: string | null): string | null { - if (structureId === null) return null; - const structure = structures.find((item) => item.structure_id === structureId); - if (!structure) return null; - const anchor = structureAnchorM(structure); - const station = irregularStations.find( - (entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M, - ); - return station ? irregularStationId(station.id) : null; - } + /** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */ + const structureIdAtStation = (stationId: string | null): string | null => + structureIdAtStationOf(stationId, irregularStations, structures); + const stationIdAtStructure = (structureId: string | null): string | null => + stationIdAtStructureOf(structureId, irregularStations, structures); /** * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). @@ -377,6 +355,7 @@ export function createRouteProfilePanel( renderBalanceBar({ minCoverViolations, balanceBar, + tools: tools.render(), alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), @@ -389,6 +368,26 @@ export function createRouteProfilePanel( }); } + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ + const { tools, history, handleToolPick } = createPanelTools({ + root, + base: () => base, + alignment: () => alignment, + edits: () => store.edits(), + applyEdits: (next) => applyEdits(next), + selectedStationId: () => selectedStationId, + irregularStations: () => irregularStations, + stationIdOf: (station) => irregularStationId(station.id), + moveStation: (station, toChainageM) => + callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), + restore: () => { + // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); + rebuild(); + }, + refresh: () => renderBalance(), + }); + /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */ function applyEdits(next: AlignmentEdits): void { if (!base || !alignment) return; @@ -404,6 +403,7 @@ export function createRouteProfilePanel( return; } store.replace(next); + history.record(); } /** @@ -424,48 +424,19 @@ export function createRouteProfilePanel( }); } - /** - * 횡단 설계 프리뷰 요청 — 끌기 중에는 계속 호출되므로 마지막 값만 보낸다. - * 응답이 늦게 와도 그 사이 편집이 더 있었으면 버린다(seq 비교). - */ - function scheduleCrossPreview(): void { - if (!detail || routeId === null) return; - window.clearTimeout(crossPreviewTimer); - crossPreviewTimer = window.setTimeout(() => { - if (!detail || routeId === null) return; - const seq = (crossPreviewSeq += 1); - const targetRouteId = routeId; - // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 - // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값 없으면 - // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. - void previewCrossDesigns(projectId, targetRouteId, store.edits(), undefined, { - fullDesigns: true, - }) - .then((next) => { - if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return; - // 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로 - // 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다. - const designByChainage = new Map( - next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of detail.cross_sections) { - const full = designByChainage.get(section.chainage_m.toFixed(3)); - if (!full || !section.design) continue; - // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. - section.design = { - ...(full as NonNullable), - inlet_structure: section.design.inlet_structure, - basin_adjust: section.design.basin_adjust, - }; - } - draw(); - callbacks?.onCrossDesignsUpdated?.(); - }) - .catch(() => { - /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ - }); - }, CROSS_PREVIEW_DEBOUNCE_MS); - } + /** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */ + const crossPreview = createCrossPreview({ + projectId, + detail: () => detail, + routeId: () => routeId, + edits: () => store.edits(), + debounceMs: CROSS_PREVIEW_DEBOUNCE_MS, + onApplied: () => { + draw(); + callbacks?.onCrossDesignsUpdated?.(); + }, + }); + const scheduleCrossPreview = (): void => crossPreview.schedule(); /** * 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지 @@ -527,6 +498,8 @@ export function createRouteProfilePanel( }, selectStation, applyEdits, + handleToolPick, + toolActive: () => tools.mode() !== "none", stationIdAtStructure, redraw: draw, }); @@ -588,7 +561,8 @@ export function createRouteProfilePanel( // 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다. if (routeChanged || !store.dirty()) { routeId = nextRouteId ?? routeId; - store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild()); + savedEdits = stored?.edits ?? emptyEdits(); + store = createProfileEditStore(routeId, savedEdits, () => rebuild()); } base = stored ? toAlignmentBase(stored) : null; // 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지). @@ -712,7 +686,7 @@ export function createRouteProfilePanel( }, dispose() { window.clearTimeout(resizeTimer); - window.clearTimeout(crossPreviewTimer); + crossPreview.dispose(); resizeObserver.disconnect(); heightResizer.dispose(); window.removeEventListener("pointerup", clearDragFlags); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts new file mode 100644 index 00000000..b765c5ba --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Panel_Tools.ts + * 종단 패널의 [직선화]·[쉬프트]·되돌리기·방향키 배선 (2026-09-02 사용자 지시). + * + * 패널 본체(`B05_Profile_UI_Profile_Panel`)가 700줄 한계라 조작 배선만 떼어 놓는다. + * 여기서는 상태를 갖지 않고 **본체가 넘긴 접근자**로만 현재 값을 읽는다 — 계획선·편집 + * 델타의 주인은 그대로 본체다. + * + * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. + * ========================================================================== */ + +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "./B05_Profile_UI_Profile_Alignment"; +import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; +import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; +import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; +import { + detectStraightRun, + shiftStraightRuns, + straightenBetween, + tiltStraightRun, +} from "./B05_Profile_UI_Profile_Straighten"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ +const KEY_STEP_M = 0.1; + +export interface PanelToolsContext { + /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ + root: HTMLElement; + base: () => AlignmentBase | null; + alignment: () => ProfileAlignment | null; + edits: () => AlignmentEdits; + applyEdits: (next: AlignmentEdits) => void; + /** 고른 측점 id(없으면 null). */ + selectedStationId: () => string | null; + /** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */ + irregularStations: () => IrregularStation[]; + /** 측점 id 만들기 — 목록 id와 그래프 id를 맞춘다. */ + stationIdOf: (station: IrregularStation) => string; + /** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */ + moveStation: (station: IrregularStation, toChainageM: number) => void; + /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ + restore: () => void; + /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ + refresh: () => void; +} + +export interface PanelTools { + tools: ProfileTools; + history: ProfileHistory; + /** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */ + handleToolPick: (chainageM: number | null) => boolean; +} + +export function createPanelTools(ctx: PanelToolsContext): PanelTools { + const history = createProfileHistory(ctx.restore); + + const tools = createProfileTools({ + onStraighten: (fromM, toM) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM)); + tools.clear(); + }, + onShift: (runs, delta) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(shiftStraightRuns(base, ctx.edits(), runs, delta)); + }, + onTilt: (run, delta) => { + const base = ctx.base(); + if (!base) return; + ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); + }, + onUndo: () => history.undo(), + onRedo: () => history.redo(), + canUndo: () => history.canUndo(), + canRedo: () => history.canRedo(), + onChanged: ctx.refresh, + }); + + /** 그래프에서 누른 자리를 도구가 먹는다. */ + function handleToolPick(chainageM: number | null): boolean { + const mode = tools.mode(); + if (mode === "none") return false; + if (chainageM === null) return tools.handleRunPick(null); + const alignment = ctx.alignment(); + if (mode === "shift") { + return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null); + } + // 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라 + // 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다. + if (tools.pendingStation() === null && alignment) { + const run = detectStraightRun(alignment, chainageM); + if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) { + return tools.handleRunPick(run); + } + } + return tools.handleStationPick(chainageM); + } + + function selectedIrregular(): IrregularStation | null { + const stationId = ctx.selectedStationId(); + if (stationId === null) return null; + return ctx.irregularStations().find((entry) => ctx.stationIdOf(entry) === stationId) ?? null; + } + + /** 고른 측점의 누가거리 — 비정규 측점이면 목록에서, 규칙 측점이면 선형에서 찾는다. */ + function selectedChainage(): number | null { + const irregular = selectedIrregular(); + if (irregular) return irregular.chainage_m; + const stationId = ctx.selectedStationId(); + const row = ctx.alignment()?.stations.find((station) => station.station_id === stationId); + return row ? row.chainage_m : null; + } + + /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. + * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 + * 측점번호가 어긋난다(2026-09-02 사용자 확정). */ + ctx.root.addEventListener("keydown", (event) => { + const target = event.target as HTMLElement | null; + // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. + if (target && target.closest("input, select, textarea")) return; + const key = event.key; + if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { + event.preventDefault(); + if (event.shiftKey) history.redo(); + else history.undo(); + return; + } + if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { + return; + } + const chainage = selectedChainage(); + const base = ctx.base(); + if (chainage === null || !base) return; + if (key === "ArrowUp" || key === "ArrowDown") { + event.preventDefault(); + const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + return; + } + const station = selectedIrregular(); + if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + event.preventDefault(); + const next = Number( + (station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3), + ); + if (next < 0) return; + ctx.moveStation(station, next); + history.record(); + }); + + return { tools, history, handleToolPick }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts new file mode 100644 index 00000000..5434af36 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -0,0 +1,79 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Preview.ts + * 계획선 편집 → 횡단 설계 프리뷰 반영 (패널 본체에서 분리, 2026-09-02 · 700줄 한계). + * + * 계획고가 바뀌면 측점별 횡단 단면적도 바뀐다 — 서버에 한 번 물어 전 측점을 다시 계산해 + * **공유 캐시가 들고 있는 같은 객체**를 제자리 갱신한다. 그래야 횡단 기준 유토곡선과 + * 3D 예상형상이 같은 값으로 따라온다(2026-08-03·08-23 사용자 보고). + * + * 끌기 중에는 계속 호출되므로 디바운스하고, 늦게 온 응답은 seq 비교로 버린다. + * ========================================================================== */ + +import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; +import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; +import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; + +export interface CrossPreviewContext { + projectId: string; + detail: () => SectionDetailResponse | null; + routeId: () => number | null; + edits: () => AlignmentEdits; + debounceMs: number; + /** 반영이 끝난 뒤 다시 그린다. */ + onApplied: () => void; +} + +export interface CrossPreview { + /** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */ + schedule: () => void; + /** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */ + dispose: () => void; +} + +export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { + let timer = 0; + let seq = 0; + + return { + schedule() { + if (!ctx.detail() || ctx.routeId() === null) return; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + const detail = ctx.detail(); + const routeId = ctx.routeId(); + if (!detail || routeId === null) return; + const current = (seq += 1); + // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 + // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면 + // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. + void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, { + fullDesigns: true, + }) + .then((next) => { + const live = ctx.detail(); + if (current !== seq || !live || ctx.routeId() !== routeId) return; + const designByChainage = new Map( + next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), + ); + for (const section of live.cross_sections) { + const full = designByChainage.get(section.chainage_m.toFixed(3)); + if (!full || !section.design) continue; + // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. + section.design = { + ...(full as NonNullable), + inlet_structure: section.design.inlet_structure, + basin_adjust: section.design.basin_adjust, + }; + } + ctx.onApplied(); + }) + .catch(() => { + /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ + }); + }, ctx.debounceMs); + }, + dispose() { + window.clearTimeout(timer); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 6f4a2d8d..2921a9a1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -21,8 +21,6 @@ import { buildAlignment, controlElevationAt, setCurveRadius, - shiftMovingPoints, - shiftSegment, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, @@ -85,6 +83,10 @@ export interface ProfileRenderContext { clearMainDragCooldown: () => void; selectStation: (stationId: string | null) => void; applyEdits: (next: AlignmentEdits) => void; + /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ + handleToolPick: (chainageM: number | null) => boolean; + /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ + toolActive: () => boolean; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; @@ -171,6 +173,23 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시). // 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation). chartWrap.addEventListener("click", (event) => { + // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 + // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). + if (ctx.toolActive()) { + const marker = (event.target as HTMLElement).closest(".b06-chart__station"); + const raw = marker?.getAttribute("data-chainage"); + if (raw !== null && raw !== undefined) { + if (ctx.handleToolPick(Number(raw))) return; + } else { + const rect = chartWrap.getBoundingClientRect(); + const chainage = chainageInverter( + longitudinal, + width, + layout.originOffset, + )(event.clientX - rect.left + chartWrap.scrollLeft); + if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; + } + } if ((event.target as HTMLElement).closest(".b06-chart__station")) return; if (ctx.selectedStationId() !== null) ctx.selectStation(null); // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 @@ -338,20 +357,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { if (blocksMinCover(next)) return; ctx.applyEdits(next); }, - onSegment: (segment, delta) => { - if (!base) return; - const next = shiftSegment(base, store.edits(), segment, delta); - if (next === store.edits() || blocksMinCover(next)) return; - ctx.applyEdits(next); - }, - // 쉬프트 가능 구간 판정 — 안쪽 미틸트 측점 2개(힌지) 확보 못 하면 버튼 숨김. - canShift: (segment) => shiftMovingPoints(alignment, segment) !== null, - onResetStation: (chainage) => store.resetStation(chainage), - // 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2). - onResetSegment: (segment) => { - store.resetStation(segment.from_m); - store.resetStation(segment.to_m); - }, }), ); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts new file mode 100644 index 00000000..1bcc587e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -0,0 +1,263 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Straighten.ts + * 종단 계획선 [직선화]·[쉬프트] 기하 (2026-09-02 사용자 지시). + * + * 초기 계획선이 **전체 측점 폴리라인**(계획고 = 지반고)으로 바뀌면서, 구간을 다루는 + * 조작은 두 가지가 된다. + * + * [직선화] 측점 2개를 고르면 두 측점의 라운드에 **탄젠트한 직선**으로 잇는다. + * 사이에 있던 라운드는 전부 지운다. 두 끝 측점은 변화점으로 남아 있어 + * 그 자리 라운드가 직선의 접선 역할을 한다. + * [쉬프트] 직선화된 라인을 고르면(복수 가능) 그 폴리라인 전체를 위·아래로 옮긴다. + * 직선 내부는 한 줄이므로 평행이동 = **최외곽 라운드 중심을 같이 옮기는 것**과 + * 같고, 바깥 고정점과는 새 직선으로 다시 이어진다. + * + * 직선 구간은 따로 저장하지 않고 **기하에서 되읽는다**(`detectStraightRun`) — 편집 + * 델타 스키마(`station_offsets`·`curve_radii`)를 그대로 두어 서버 계약을 건드리지 않는다. + * ========================================================================== */ + +import type { + AlignmentBase, + AlignmentEdits, + ProfileAlignment, +} from "./B05_Profile_UI_Profile_Alignment"; +import { + buildAlignment, + chainageKey, + controlElevationAt, +} from "./B05_Profile_UI_Profile_Alignment"; + +/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */ +const COLLINEAR_EPSILON = 1e-6; +/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */ +const SAME_STATION_M = 1e-6; + +/** 직선화된 한 구간 — 양 끝 변화점과 그 사이 측점들. */ +export interface StraightRun { + fromM: number; + toM: number; + /** 양 끝을 포함한 구간 안 변화점 chainage (오름차순). */ + nodes: number[]; +} + +/** 현재 선형의 변화점 chainage 목록 (오름차순). */ +function pviChainages(alignment: ProfileAlignment): number[] { + return alignment.pvi.map((node) => node.chainage_m); +} + +/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */ +function baseElevationAt(base: AlignmentBase, chainageM: number): number { + const nodes = base.basePvi; + if (!nodes.length) return 0; + if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m; + const last = nodes[nodes.length - 1]; + if (chainageM >= last.chainage_m) return last.elevation_m; + for (let index = 1; index < nodes.length; index += 1) { + if (nodes[index].chainage_m < chainageM) continue; + const previous = nodes[index - 1]; + const current = nodes[index]; + const span = current.chainage_m - previous.chainage_m; + const ratio = span > 1e-12 ? (chainageM - previous.chainage_m) / span : 0; + return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; + } + return last.elevation_m; +} + +/** 변화점 사이 기울기 (index 번째 구간). */ +function gradeAt(alignment: ProfileAlignment, index: number): number { + const segment = alignment.segments[index]; + if (!segment || segment.length_m <= 1e-12) return 0; + return segment.height_m / segment.length_m; +} + +/** + * 그 자리를 지나는 **최대 직선 구간**을 찾는다. + * + * 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점 + * 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만 + * 두 측점 이상으로 이어진 직선이 된다. + */ +export function detectStraightRun( + alignment: ProfileAlignment, + chainageM: number, +): StraightRun | null { + const nodes = pviChainages(alignment); + if (nodes.length < 2) return null; + // 클릭 지점을 포함하는 구간 index + let index = alignment.segments.findIndex( + (segment) => + chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M, + ); + if (index < 0) return null; + const grade = gradeAt(alignment, index); + let first = index; + let last = index; + while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) { + first -= 1; + } + while ( + last < alignment.segments.length - 1 && + Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON + ) { + last += 1; + } + // 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다. + if (last === first) return null; + const fromM = alignment.segments[first].from_m; + const toM = alignment.segments[last].to_m; + return { + fromM, + toM, + nodes: nodes.filter( + (value) => value >= fromM - SAME_STATION_M && value <= toM + SAME_STATION_M, + ), + }; +} + +/** 두 측점 사이(끝 제외)의 변화점 chainage. */ +function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] { + return pviChainages(alignment).filter( + (value) => value > fromM + SAME_STATION_M && value < toM - SAME_STATION_M, + ); +} + +/** 편집 델타에 측점 오프셋을 써 넣는다(기존 객체는 건드리지 않는다). */ +function withOffsets( + edits: AlignmentEdits, + updates: Array<[string, number]>, + dropRadiusKeys: string[] = [], +): AlignmentEdits { + const stationOffsets = { ...edits.station_offsets }; + const curveRadii = { ...edits.curve_radii }; + updates.forEach(([key, value]) => { + stationOffsets[key] = Number(value.toFixed(6)); + }); + dropRadiusKeys.forEach((key) => delete curveRadii[key]); + return { station_offsets: stationOffsets, curve_radii: curveRadii }; +} + +/** + * 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다. + * + * 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의 + * 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다). + */ +export function straightenBetween( + base: AlignmentBase, + edits: AlignmentEdits, + fromChainageM: number, + toChainageM: number, +): AlignmentEdits { + const fromM = Math.min(fromChainageM, toChainageM); + const toM = Math.max(fromChainageM, toChainageM); + const span = toM - fromM; + if (span <= SAME_STATION_M) return edits; + const current = buildAlignment(base, edits); + const startZ = controlElevationAt(current, fromM); + const endZ = controlElevationAt(current, toM); + const inner = interiorNodes(current, fromM, toM); + if (!inner.length) return edits; + const updates: Array<[string, number]> = inner.map((chainage) => { + const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span; + return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; + }); + return withOffsets(edits, updates, inner.map(chainageKey)); +} + +/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */ +function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number { + const length = base.policy.default_curve_length_m; + if (typeof length === "number" && Number.isFinite(length) && length > 0) return length; + return base.policy.default_curve_radius_m * Math.abs(deltaGrade); +} + +/** + * 직선화된 구간을 위·아래로 꺾는다 — **가운데 측점에 라운드를 넣고 양쪽에 탄젠트 직선**. + * + * 가운데 측점을 delta만큼 옮기고, 그 좌우를 각각 새 직선 위에 다시 올린다. 라운드는 + * 그 변화점에만 생기며(전체 측점 폴리라인은 R을 지정한 자리에만 라운드를 만든다), + * R은 기본 곡선길이 L에서 R = L / |대수차| 로 역산해 둔다. + */ +export function tiltStraightRun( + base: AlignmentBase, + edits: AlignmentEdits, + run: StraightRun, + delta: number, +): AlignmentEdits { + const current = buildAlignment(base, edits); + const inner = interiorNodes(current, run.fromM, run.toM); + if (!inner.length) return edits; + const center = (run.fromM + run.toM) / 2; + const pivot = inner.reduce((best, chainage) => + Math.abs(chainage - center) < Math.abs(best - center) ? chainage : best, + ); + const startZ = controlElevationAt(current, run.fromM); + const endZ = controlElevationAt(current, run.toM); + const span = run.toM - run.fromM; + const pivotZ = + startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta; + + const updates: Array<[string, number]> = [ + [chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)], + ]; + const place = (chainage: number, aM: number, aZ: number, bM: number, bZ: number): void => { + const width = bM - aM; + if (width <= 1e-12) return; + const target = aZ + ((bZ - aZ) * (chainage - aM)) / width; + updates.push([chainageKey(chainage), target - baseElevationAt(base, chainage)]); + }; + inner.forEach((chainage) => { + if (Math.abs(chainage - pivot) < SAME_STATION_M) return; + if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ); + else place(chainage, pivot, pivotZ, run.toM, endZ); + }); + + const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12); + const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12); + const deltaGrade = Math.abs(gradeOut - gradeIn); + const next = withOffsets( + edits, + updates, + inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey), + ); + if (deltaGrade < 1e-9) return next; + return { + ...next, + curve_radii: { + ...next.curve_radii, + [chainageKey(pivot)]: Number((defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6)), + }, + }; +} + +/** + * 고른 직선 구간들을 통째로 위·아래로 옮긴다. + * + * 직선 내부는 한 줄이므로 구간 안 변화점을 모두 같은 델타로 옮기는 것이 곧 + * **최외곽 라운드 중심을 옮기는 것**이다(사이 점들은 그 사이에 그대로 실려 간다). + * 바깥 고정점(BP·EP·다른 변화점)과는 새 기울기의 직선으로 다시 이어진다. + */ +export function shiftStraightRuns( + base: AlignmentBase, + edits: AlignmentEdits, + runs: StraightRun[], + delta: number, +): AlignmentEdits { + if (!runs.length) return edits; + const current = buildAlignment(base, edits); + const moving = new Set(); + runs.forEach((run) => run.nodes.forEach((chainage) => moving.add(chainage))); + const updates: Array<[string, number]> = [...moving].map((chainage) => { + const target = controlElevationAt(current, chainage) + delta; + return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; + }); + return withOffsets(edits, updates); +} + +/** 같은 구간인지 — 선택 목록에서 중복을 걸러낼 때 쓴다. */ +export function sameRun(left: StraightRun, right: StraightRun): boolean { + return ( + Math.abs(left.fromM - right.fromM) < SAME_STATION_M && + Math.abs(left.toM - right.toM) < SAME_STATION_M + ); +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index b23b6a21..c22646ad 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -10,7 +10,12 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { + irregularStationId, + isPipeStation, + type IrregularStation, +} from "./B05_Profile_UI_IrregularStations"; +import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; /** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ @@ -117,3 +122,42 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti host.append(menu.element); } + +/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */ +export const SAME_CHAINAGE_M = 0.51; + +/** + * 측점선 id ↔ 알약(구조물) id 짝짓기. + * + * 둘은 한 구조물의 두 표시라 선택이 함께 움직여야 한다(2026-08-17 사용자 지시). + * 패널 본체가 700줄 한계라 이 짝짓기만 여기로 옮겼다(2026-09-02). + */ +export function structureIdAtStation( + stationId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], +): string | null { + if (stationId === null) return null; + const prefix = irregularStationId(""); + if (!stationId.startsWith(prefix)) return null; + const station = stations.find((entry) => irregularStationId(entry.id) === stationId); + if (!station) return null; + const hit = structures.find( + (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, + ); + return hit?.structure_id ?? null; +} + +/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ +export function stationIdAtStructure( + structureId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], +): string | null { + if (structureId === null) return null; + const structure = structures.find((item) => item.structure_id === structureId); + if (!structure) return null; + const anchor = structureAnchorM(structure); + const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M); + return station ? irregularStationId(station.id) : null; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts new file mode 100644 index 00000000..e42c66a6 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Tools.ts + * 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시). + * + * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위 + * 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유). + * + * 선택 흐름: + * [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체. + * [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림. + * 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에 + * 라운드가 생기고 양쪽이 탄젠트 직선이 된다. + * ========================================================================== */ + +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; +import { sameRun } from "./B05_Profile_UI_Profile_Straighten"; + +export type ProfileToolMode = "none" | "straighten" | "shift"; + +export interface ProfileToolsCallbacks { + /** 두 측점을 직선으로 잇는다. */ + onStraighten: (fromChainageM: number, toChainageM: number) => void; + /** 고른 직선 구간(들)을 위·아래로 옮긴다. */ + onShift: (runs: StraightRun[], delta: number) => void; + /** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */ + onTilt: (run: StraightRun, delta: number) => void; + onUndo: () => void; + onRedo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + /** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */ + onChanged: () => void; +} + +export interface ProfileTools { + /** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */ + render: () => HTMLElement; + mode: () => ProfileToolMode; + /** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */ + handleStationPick: (chainageM: number) => boolean; + /** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */ + handleRunPick: (run: StraightRun | null) => boolean; + /** 선택 중인 직선 구간들(강조 표시용). */ + selectedRuns: () => StraightRun[]; + /** 직선화 대기 중 첫 측점(강조 표시용). */ + pendingStation: () => number | null; + /** 모드·선택을 모두 끈다. */ + clear: () => void; +} + +function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-route-profile__tool"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + event.stopPropagation(); + onClick(); + }); + return button; +} + +export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileTools { + let mode: ProfileToolMode = "none"; + let pending: number | null = null; + let runs: StraightRun[] = []; + + function reset(): void { + mode = "none"; + pending = null; + runs = []; + } + + function setMode(next: ProfileToolMode): void { + // 같은 버튼을 다시 누르면 모드를 끈다 — 선택도 함께 비운다. + if (mode === next) reset(); + else { + mode = next; + pending = null; + runs = []; + } + callbacks.onChanged(); + } + + function step(delta: number): void { + if (mode === "shift" && runs.length) { + callbacks.onShift(runs, delta); + return; + } + if (runs.length === 1) callbacks.onTilt(runs[0], delta); + } + + return { + mode: () => mode, + selectedRuns: () => runs, + pendingStation: () => pending, + clear() { + if (mode === "none" && pending === null && !runs.length) return; + reset(); + callbacks.onChanged(); + }, + handleStationPick(chainageM) { + if (mode !== "straighten") return false; + if (pending === null) { + pending = chainageM; + callbacks.onChanged(); + return true; + } + const from = pending; + pending = null; + if (Math.abs(from - chainageM) < 1e-6) { + callbacks.onChanged(); + return true; + } + callbacks.onStraighten(from, chainageM); + return true; + }, + handleRunPick(run) { + if (mode === "none") return false; + if (!run) { + // 빈 곳을 누르면 선택만 비우고 모드는 유지한다 — 연속 조작을 끊지 않는다. + if (runs.length || pending !== null) { + runs = []; + pending = null; + callbacks.onChanged(); + } + return true; + } + const already = runs.findIndex((entry) => sameRun(entry, run)); + if (already >= 0) runs.splice(already, 1); + else if (mode === "shift") runs.push(run); + else runs = [run]; + callbacks.onChanged(); + return true; + }, + render() { + const wrap = document.createElement("span"); + wrap.className = "b05-route-profile__tools"; + + const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo); + undo.disabled = !callbacks.canUndo(); + const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo); + redo.disabled = !callbacks.canRedo(); + + const straighten = toolButton( + "직선화", + "측점 2개를 골라 그 사이를 직선으로 만듭니다 (사이 라운드는 지워집니다)", + () => setMode("straighten"), + ); + straighten.classList.toggle("is-active", mode === "straighten"); + const shift = toolButton( + "쉬프트", + "직선화된 라인을 골라(여러 개 가능) 위·아래로 옮깁니다", + () => setMode("shift"), + ); + shift.classList.toggle("is-active", mode === "shift"); + + const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1)); + const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1)); + const idle = mode === "none" || (!runs.length && mode === "shift"); + up.disabled = idle || (mode === "straighten" && runs.length !== 1); + down.disabled = up.disabled; + + wrap.append(undo, redo, straighten, shift, up, down); + if (mode === "straighten" && pending !== null) { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요`; + wrap.append(hint); + } else if (mode === "shift" && !runs.length) { + const hint = document.createElement("em"); + hint.className = "b05-route-profile__tool-hint"; + hint.textContent = "직선화된 라인을 고르세요"; + wrap.append(hint); + } + return wrap; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index c27e729a..94062e8f 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -544,17 +544,45 @@ text-overflow: ellipsis; } -.b05-route-profile__balance-reset { +/* 도구줄 — 요약줄 맨 앞(최대 기울기 칩 왼쪽). 버튼 크기는 그래프 위 틸팅 버튼과 같다 + (21×17px, 2026-09-02 사용자 지시). */ +.b05-route-profile__tools { + display: inline-flex; flex: 0 0 auto; - padding: 1px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); + align-items: center; + gap: 2px; + margin-right: var(--spacing-8); +} + +.b05-route-profile__tool { + height: 17px; + min-width: 21px; + padding: 0 3px; + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); + color: var(--color-text); + font-size: 9px; + line-height: 1; cursor: pointer; } +.b05-route-profile__tool:disabled { + opacity: 0.35; + cursor: default; +} + +.b05-route-profile__tool.is-active { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 22%, transparent); +} + +.b05-route-profile__tool-hint { + color: var(--color-text-muted); + font-size: var(--text-caption); + font-style: normal; +} + /* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 4599b034..5cccae88 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -284,6 +284,8 @@ export function createLongitudinalProfile( const selected = station.station_id === selectedStationId; const marker = svgElement("g", { class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + // B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02). + "data-chainage": station.chainage_m.toFixed(3), tabindex: "0", role: "button", "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, -- 2.54.0 From e065e5701e01701553d61110b3a374a99db3ce95 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 2 Sep 2026 20:34:18 +0900 Subject: [PATCH 012/441] =?UTF-8?q?fix(B05):=20=EC=B5=9C=EC=86=8C=EA=B3=A0?= =?UTF-8?q?=20=EA=B0=80=EB=93=9C=EB=A5=BC=20=ED=8E=B8=EC=A7=91=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=B0=BD=EA=B5=AC=EB=A1=9C=20=EC=9D=B4=EA=B4=80=20?= =?UTF-8?q?+=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=EA=B0=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=9C=84=EC=B9=98=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-09-02 화면 검증에서 나온 미해결 2건 처리. - 최소고 가드: `blocksMinCover` 가 `_Profile_Render` 측점 끌기 한 곳에만 있어 [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나감. 판정 로직을 `_Profile_MinCover.blocksMinCover()` 로 옮기고 모든 편집이 지나는 `_Profile_Panel.applyEdits` 한 곳에서만 호출. 화면 모듈의 사본·죽은 배선 (`enforceMinCover` ctx 필드, 미사용 import)은 제거. - 되돌리기 범위: 이력이 `b05:`·`b05-`·`b06:`·`b06-` 세션 키만 스냅샷하는데 구조물(관) 위치는 세션 밖이라 undo 가 계획선만 되돌림. `_Profile_Panel_Tools` 가 기록 직전에 관 목록을 `b05:pipes` 로 흘리고 복원 뒤 되읽어 반영. 사용자 확정 ⑥ 「범위는 B05 페이지 조작 전부」 충족. 검증(공용 브라우저, 용화 검증 프로젝트 route_id 126): - 최소고 강제 켠 상태로 관 측점 264.060 방향키 ↓ → 편집 미적용 + 경고 "배수관 Ø800 — 최소 계획고(지반 +1.3m) 아래로 내려갈 수 없습니다." - 구조물 측점 264.060 → 263.960 이동 후 ↶ 1회 → 264.060 복귀, `b05:pipes` 목록도 264.06 으로 복귀. - `tsc --noEmit` 0, prettier 통과, pytest 366 passed / 17 skipped / 0 failed. - 파일 줄수: Panel 699 · Panel_Tools 197 · Render 350 · MinCover 188 (700줄 제한 준수). Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Profile_MinCover.ts | 59 +++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 5 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 48 ++++++++++++-- B05_Profile/B05_Profile_UI_Profile_Render.ts | 65 ++----------------- 4 files changed, 112 insertions(+), 65 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts index c76ed544..e52f83b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -19,6 +19,10 @@ * 경고하기 위한 같은 산식의 화면 사본이며, 상수 일치는 테스트로 잠가 둔다. * ========================================================================== */ +import { showToast } from "@ui/ui_template_elements"; +import { controlElevationAt } from "./B05_Profile_UI_Profile_Alignment"; +import type { AlignmentBase, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; /** 최소고 판정에 필요한 것만 받는다 — 관 목록의 출처(정본/화면)에 매이지 않는다. */ @@ -127,3 +131,58 @@ export function minCoverWarningText(violations: MinCoverViolation[]): string | n const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : ""; return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`; } + +/** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */ +function groundAt(base: AlignmentBase, chainageM: number): number | null { + const { chainage: xs, ground: ys } = base; + if (!xs.length) return null; + if (chainageM <= xs[0]) return ys[0]; + if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; + for (let i = 1; i < xs.length; i += 1) { + if (chainageM > xs[i]) continue; + const span = xs[i] - xs[i - 1]; + if (span <= 0) return ys[i]; + return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); + } + return ys[ys.length - 1]; +} + +/** + * 편집 후보가 최소 계획고를 깨면 막는다(2026-08-23 개편). + * + * 위반이 **새로 생기거나 커질 때만** 막는다 — 이미 위반이면 악화만 막아 복구(올림) + * 편집은 언제나 통과한다. 판정점은 제어점 z(라운드 중심)다: 곡선 샘플로 재면 이웃 + * 틸팅이 라운드 형상만 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). + * + * 호출 자리는 `_Profile_Panel.applyEdits` **한 곳**이다. 측점 끌기에만 걸어 두었더니 + * [직선화]·[쉬프트]·틸팅·방향키가 그냥 지나갔다(2026-09-02 laptop-main 실측). + * 최소고 강제가 꺼져 있으면(기본 해제, 2026-09-01 사용자 지시) 아무것도 막지 않는다. + */ +export function blocksMinCover( + base: AlignmentBase | null, + alignment: ProfileAlignment | null, + candidate: ProfileAlignment, + enforced: boolean, + targets: MinCoverPoint[], +): boolean { + if (!base || !alignment || !enforced || !targets.length) return false; + const ground = (chainageM: number) => groundAt(base, chainageM); + const planned = findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(candidate, chainageM), + ); + if (!planned.length) return false; + const current = new Map( + findMinCoverViolations(targets, ground, (chainageM) => + controlElevationAt(alignment, chainageM), + ).map((violation) => [violation.chainage_m, violation.shortfall_m]), + ); + const worsened = planned.find( + (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, + ); + if (!worsened) return false; + showToast( + `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, + "warning", + ); + return true; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 01e5ea34..5d127f36 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -26,6 +26,7 @@ import { showToast } from "@ui/ui_template_elements"; import { saveProfileAlignment } from "./B05_Profile_Api_Fetch"; import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common"; import { + blocksMinCover, findMinCoverViolations, minCoverPoints, type MinCoverPoint, @@ -380,6 +381,7 @@ export function createRouteProfilePanel( stationIdOf: (station) => irregularStationId(station.id), moveStation: (station, toChainageM) => callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station), + drainage: drainagePanel, restore: () => { // 세션이 정본이므로 편집 초안을 다시 읽어 그린다. store = createProfileEditStore(routeId, savedEdits, () => rebuild()); @@ -402,6 +404,8 @@ export function createRouteProfilePanel( ); return; } + // 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정). + if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return; store.replace(next); history.record(); } @@ -475,7 +479,6 @@ export function createRouteProfilePanel( stationInterval: () => stationInterval, irregularStations: () => irregularStations, minCoverTargets: () => minCoverTargets, - enforceMinCover: () => enforceMinCover, structures: () => structures, structureTypes: () => structureTypes, selectedStationId: () => selectedStationId, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index b765c5ba..a22a7aaa 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -28,6 +28,9 @@ import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; /** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */ const KEY_STEP_M = 0.1; +/** 구조물(관) 위치를 이력에 태우는 세션 키 — `b05:` 접두라 스냅샷 대상에 든다. */ +const PIPE_KEY = "b05:pipes"; + export interface PanelToolsContext { /** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */ root: HTMLElement; @@ -43,6 +46,11 @@ export interface PanelToolsContext { stationIdOf: (station: IrregularStation) => string; /** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */ moveStation: (station: IrregularStation, toChainageM: number) => void; + /** 관 목록 정본 — 되돌리기가 구조물 위치까지 되돌리려면 이력이 이 값을 봐야 한다. */ + drainage: { + pipeChainages: () => number[]; + setPipeChainages: (chainages: number[]) => void; + }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ @@ -57,7 +65,37 @@ export interface PanelTools { } export function createPanelTools(ctx: PanelToolsContext): PanelTools { - const history = createProfileHistory(ctx.restore); + /** 이력이 처음 본 관 목록 — 최초 스냅샷에는 아직 키가 없어 여기로 되돌린다. */ + let initialPipes: number[] | null = null; + + /** 관 위치를 스냅샷이 볼 수 있는 세션 키로 옮겨 적는다(기록 직전에 부른다). */ + function syncPipes(): void { + const chainages = ctx.drainage.pipeChainages(); + if (initialPipes === null) initialPipes = chainages; + sessionStorage.setItem(PIPE_KEY, JSON.stringify(chainages)); + } + + /** 스냅샷을 되돌린 뒤 관 위치도 그 시점 값으로 맞춘다. 같은 목록이면 조용히 끝난다. */ + function restorePipes(): void { + let chainages = initialPipes; + const raw = sessionStorage.getItem(PIPE_KEY); + if (raw) { + try { + chainages = JSON.parse(raw) as number[]; + } catch { + // 손상된 값은 최초 목록으로 되돌린다. + } + } + if (chainages) ctx.drainage.setPipeChainages(chainages); + } + + // 계획선 편집만 되돌리면 구조물 위치가 어긋난다(사용자 확정 ⑥ — 범위는 B05 조작 전부). + const inner = createProfileHistory(() => { + ctx.restore(); + restorePipes(); + }); + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ + const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { @@ -76,10 +114,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (!base) return; ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); }, - onUndo: () => history.undo(), - onRedo: () => history.redo(), - canUndo: () => history.canUndo(), - canRedo: () => history.canRedo(), + onUndo: () => inner.undo(), + onRedo: () => inner.redo(), + canUndo: () => inner.canUndo(), + canRedo: () => inner.canRedo(), onChanged: ctx.refresh, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 2921a9a1..5f53b99d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -8,7 +8,6 @@ * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; import { createLongitudinalProfile, longitudinalMinimumWidth, @@ -18,15 +17,13 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { adjustStation, - buildAlignment, - controlElevationAt, setCurveRadius, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; -import { findMinCoverViolations, type MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; +import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { @@ -64,10 +61,9 @@ export interface ProfileRenderContext { base: () => AlignmentBase | null; stationInterval: () => number | undefined; irregularStations: () => IrregularStation[]; - /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */ + /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 요약줄 경고 표시에 쓴다. + * 편집 차단 가드는 `_Profile_Panel.applyEdits` 한 곳으로 옮겼다(2026-09-02). */ minCoverTargets: () => MinCoverPoint[]; - /** 최소고를 편집에서 강제할지 — 꺼져 있으면 차단하지 않는다(2026-09-01, 기본 해제). */ - enforceMinCover: () => boolean; structures: () => StructureInstance[]; structureTypes: () => StructureType[]; selectedStationId: () => string | null; @@ -291,55 +287,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); if (alignment) { - // 횡단배수 최소 계획고 가드(2026-08-23 개편): 편집 **후보**로 정렬을 미리 계산해 - // 시설별 최소고(배수관 관경+토피 · BOX암거 구체높이+토피 · 세월교 +물넘이 몫, - // 산식은 minCoverPoints 동일 원천) 위반이 **새로 생기거나 커지면** 차단한다. - // 측점 ▼뿐 아니라 구간 ⇧⇩, 이웃 틸트가 종단곡선(중앙종거)을 거쳐 배관 계획고를 - // 내리는 경로까지 같은 가드로 잡는다 — 기존 관경 고정 산식은 구간 쉬프트를 아예 - // 안 막았고 BOX암거·세월교를 과소 차단했다(2026-08-23 사용자 보고). - // 이미 위반이면 악화만 막는다 — 복구 편집(올림)은 항상 허용돼야 한다. - const groundAt = (chainageM: number): number | null => { - if (!base || !base.chainage.length) return null; - const { chainage: xs, ground: ys } = base; - if (chainageM <= xs[0]) return ys[0]; - if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1]; - for (let i = 1; i < xs.length; i += 1) { - if (chainageM > xs[i]) continue; - const span = xs[i] - xs[i - 1]; - if (span <= 0) return ys[i]; - return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span); - } - return ys[ys.length - 1]; - }; - const blocksMinCover = (next: AlignmentEdits): boolean => { - if (!base) return false; - // 최소고 강제가 꺼져 있으면 막지 않는다(2026-09-01 사용자 지시 — 기본 해제). - // 부족분은 상단 표시줄 경고(minCoverWarningText)로 계속 알린다. - if (!ctx.enforceMinCover()) return false; - const targets = ctx.minCoverTargets(); - if (!targets.length) return false; - // 판정점 = 제어점 z(라운드 중심). 곡선 샘플로 재면 이웃 틸팅이 라운드 형상만 - // 바꿔도 잠긴다(2026-08-23 사용자: "옆 지점 틸팅에 락 — 말이 안 됨"). - const candidate = buildAlignment(base, next); - const planned = findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(candidate, chainageM), - ); - if (!planned.length) return false; - const current = new Map( - findMinCoverViolations(targets, groundAt, (chainageM) => - controlElevationAt(alignment, chainageM), - ).map((violation) => [violation.chainage_m, violation.shortfall_m]), - ); - const worsened = planned.find( - (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, - ); - if (!worsened) return false; - showToast( - `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, - "warning", - ); - return true; - }; chartWrap.append( createEditOverlay({ alignment, @@ -351,11 +298,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { (entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), + // 최소고 가드는 `_Profile_Panel.applyEdits` 한 곳에 있다 — 여기 따로 걸면 + // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; - const next = adjustStation(base, store.edits(), chainage, delta); - if (blocksMinCover(next)) return; - ctx.applyEdits(next); + ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); }, }), ); -- 2.54.0 From 8daadf2d4f1a3393029d135ff1965d36250dbf93 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 07:19:32 +0900 Subject: [PATCH 013/441] =?UTF-8?q?fix(B06):=20I=ED=98=95=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=B0=EA=B4=80=20=EB=81=9D=EB=8B=A8?= =?UTF-8?q?=EC=9D=B4=20=EB=A7=88=EA=B0=90=EB=A9=B4=EC=97=90=20=EC=A0=91?= =?UTF-8?q?=ED=98=80=20=EC=82=BC=EA=B0=81=ED=98=95=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=B0=ED=95=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관 끝단 두 꼭짓점을 마감면(I형 벽 안쪽 변) 선분 안으로 자를 때, 원지반이 벽 상단 위라 관 시작점이 벽 상단에 붙으면 위·아래 교점이 같은 끝점으로 접혀 관 단면이 삼각형으로 그려짐. 비스듬히 자른 끝단 길이는 항상 관경 이상이므로, 그보다 짧으면 접힌 것으로 보고 축 직각 마감으로 되돌림. 검증 — 공용 브라우저 B06 단면 8개 실측: 유입측 끝단 길이 0.0px → 18.7~58.4px. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 6ebc313a..500d1d40 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -561,7 +561,17 @@ export function pipeAxisSolver( Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) > STRAY_LIMIT_M; if (strayed) return fallback; - return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) }; + const cutBottom = clampToFace(bottom, face); + const cutTop = clampToFace(top, face); + // 마감면을 비스듬히 자르면 끝단 길이는 관경 이상이다. 그보다 짧으면 선분 밖으로 + // 나간 교점이 한 끝점으로 접힌 것 — I형 벽이 원지반에 묻혀 관 시작점이 벽 상단에 + // 붙으면 두 꼭짓점이 한 점이 되어 관이 삼각형으로 그려진다. 그럴 땐 축 직각 마감으로. + const cutSpan = Math.hypot( + cutTop.offset - cutBottom.offset, + cutTop.elevation - cutBottom.elevation, + ); + if (cutSpan < diameter - 1e-6) return fallback; + return { bottom: cutBottom, top: cutTop }; }; solver.derive(); return solver; -- 2.54.0 From 0e0dd74e928c30b73b24332d230697a0b50b78a2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 07:53:32 +0900 Subject: [PATCH 014/441] =?UTF-8?q?feat(B05/B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=9A=94=EC=95=BD=EC=A4=84=EC=97=90=20?= =?UTF-8?q?=EC=B5=9C=EC=A2=85=20=EB=88=84=EA=B0=80=ED=86=A0=EB=9F=89=20?= =?UTF-8?q?=EC=B9=A9=20=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 평형선·장비 띠·물량 balloon 이 안 생기는 노선에서 그 이유를 화면에서 읽을 수 있게, 곡선 끝의 누가토량을 요약 칩으로 드러냄. 0에 가까울수록 절·성토 균형이며 곡선이 0선을 넘지 않으면 평형선이 서지 않는다는 설명을 툴팁에 담음. 판정·조정은 사용자 몫이라 자동 보정 로직은 두지 않음(2026-09-03 사용자 확정). B05 종단 패널과 B06 횡단 화면이 같은 함수를 쓰므로 양쪽에 함께 반영됨. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_mass_haul_view.ts | 11 +++++++++++ ui_template/ui_template_locale_b2.ts | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 957dd1c2..f981e629 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -662,6 +662,17 @@ export function createMassHaulSummary( ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], ); + // 절·성토 균형 지표 — 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다(2026-09-03 + // 사용자 확정). 곡선이 0선을 넘지 않으면 평형선·운반 블록이 아예 생기지 않으므로, + // 벌룬이 없을 때 왜 없는지를 이 값 하나로 읽을 수 있어야 한다. + const finalCumulative = result.points.length + ? result.points[result.points.length - 1].cumulative_volume_m3 + : 0; + chips.push([ + L("B06_MassHaul_FinalCumulative"), + `${formatVolume(finalCumulative)}㎥`, + L("B06_MassHaul_FinalCumulative_Tip"), + ]); // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). chips.push(...haulPlanChips(haulPlan)); for (const [label, value, tip] of chips) { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 08f2f002..4d9e4080 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -348,6 +348,14 @@ export const ui_locales_b2 = { B06_MassHaul_Shortage: ["토취", "Borrow"], /* 운반계획 도면은 다짐상태 기준으로 계산한다(국도건설공사 설계실무 요령). */ B06_MassHaul_Basis: ["다짐상태 기준", "Compacted basis"], + /* 최종 누가토량 = 절·성토 균형 지표. 판정·조정은 사용자 몫이고 프로그램은 값만 드러낸다 + (2026-09-03 사용자 확정). 실무 유토곡선 6건 관측치는 +49~+324㎥ 범위다 + — resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md §3 */ + B06_MassHaul_FinalCumulative: ["최종 누가토량", "Final cumulative"], + B06_MassHaul_FinalCumulative_Tip: [ + "노선 끝의 누가토량 — 0에 가까울수록 절·성토 균형. 곡선이 0선을 넘지 않으면 평형선·운반 블록이 생기지 않습니다.", + "Cumulative volume at the end of the route — the closer to zero, the better cut/fill balance. No balance lines or haul blocks appear while the curve never crosses zero.", + ], B06_MassHaul_AllHidden: [ "표시할 곡선을 하나 이상 켜 주세요.", "Turn on at least one curve to display.", -- 2.54.0 From 698f8f0505ee2783a8f3b82d8ebf400345c615da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 07:53:32 +0900 Subject: [PATCH 015/441] =?UTF-8?q?knowledge(=EC=9E=84=EB=8F=84):=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=ED=86=A0=EB=9F=89?= =?UTF-8?q?=EB=B0=B0=EB=B6=84=20=EA=B8=B0=EC=88=A0=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20+=20=EB=AF=B8=EA=B2=B0=2020=C2=B721=20?= =?UTF-8?q?=EB=93=B1=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 유토곡선 원본 6건(.MASS · EARTH.DAT) 파싱 관측 수록 — 최종 누가토량 +49~+324㎥, 0선 교차 3~17회, 장비 한계거리 전 건 무대 20.0 / 도자 60.0 m. 평형선 자동 선정·극값 무시 임계·balloon 표기 관행은 현행 근거 공백임을 명시하고 미결 20 으로 등재. 도자 한계거리 60m(실무) vs 70m(현행 구현) 채택은 미결 21. Co-Authored-By: Claude Opus 5 (1M context) --- resources/knowledge/03_미결_및_확인사항.md | 2 + .../01_임도/03_계산정보/유토곡선_토량배분.md | 94 +++++++++++++++++++ .../01_임도/03_계산정보/토량계산.md | 2 +- 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md diff --git a/resources/knowledge/03_미결_및_확인사항.md b/resources/knowledge/03_미결_및_확인사항.md index 7e849b3d..b0a4f55d 100644 --- a/resources/knowledge/03_미결_및_확인사항.md +++ b/resources/knowledge/03_미결_및_확인사항.md @@ -10,6 +10,8 @@ | 14 | [공통] 설계심사 체크리스트 — 실무 사례 대조 | **정리 완료(2026-08-13)**: 사방 = 고시 별표2 전 24항 표화([02_타당성평가 §4](technical_info/02_사방댐/01_대상지_타당성평가/02_타당성평가.md)), 임도 = 규정 제12조의2+별지18 9항 반영([설계제원_총괄 §10](technical_info/01_임도/02_상세설계/설계제원_총괄.md)). 잔여 = **실무 심의 사례와 대조** (사례 자료 미확보) | 심의 사례 자료 확보 시 대조 | 좌동 | 자료 대기 (2026-08-13) | | 15 | [공통] 사방댐 부위별 강도 실무값 (구 No.6 통합) | 잔여 = 부위별(본체/방수로 부배합/물받이) 강도 수치 관측 — **콘크리트 사방댐 실시설계 내역서** 필요 (기존 실무문서는 혼합쌓기라 데이터 없음). 부위 구분 4단위는 해소됨([콘크리트_재료 §1-1](technical_info/01_임도/02_상세설계/구조물/콘크리트_재료.md)). 선정=개발 단계 | 내역서 사용자 제공 대기 | [02_사방댐/05_원가정보/01_원가_특이사항](technical_info/02_사방댐/05_원가정보/01_원가_특이사항.md) §3 | 자료 대기 (2026-08-13) | | 19 | [공통] ★STmate 실기 환경 확보 (STC 입출력 검증 전제) | STC = 발주처 제출 최종물, Aislo import/export 필수 요구사항(사용자 확정 2026-08-15). 포맷 명세는 [original/원가계산/STmate/](original/원가계산/STmate/) 정리 완료. **잔여 = STmate 실행 환경** — 생성·변형 STC를 열기→조회→수정→재계산→저장→재개방까지 검증해야 빈 테이블(BDQTY·SYSINFO) export 허용 여부·신규생성 규칙 확정 가능. 환경 없으면 [STC_왕복검증](original/원가계산/STmate/STC_왕복검증.md) R1~R9 실행 불가 | 선택지: ① STmate 보유·구매 ② 체험판 ③ 설계사무소 협조. **개발 단계 사용자 결정** | [STmate 포맷 명세](original/원가계산/STmate/_meta.md), [STC_난독화_상태](original/원가계산/STmate/STC_난독화_상태.md) | 미결 — 사용자 결정 대기 (2026-08-15) | +| 20 | [임도] 유토곡선 평형선·극값 임계·balloon 표기 기준 | 법령·행정규칙·표준시방서·교본에 **세부 규칙 없음**(2026-09-03 확인). KDS 44 30 00 2.3.3은 「구간별 균형 배분·운반거리 최소화」까지만 규정. 실무 도면(`유토곡선.dwg`)은 압축 DWG라 표기 문자열 추출 불가 — 표기 관행은 도면 육안 확인 필요. 실무 곡선 원본 6건 관측치는 문서에 정리 | 실무 유토곡선 도면 육안 확인 후 사용자 협의로 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §2·§3 | 미결 — 근거 공백 (2026-09-03) | +| 21 | [임도] 토공 운반장비 한계거리 — 도자 60m vs 70m | 실무 `EARTH.DAT` 헤더 6건 전부 `무대 20.0 / 도자 60.0`. Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`은 20/70. 현행 법령·품셈에 장비별 한계거리 규정 없음 — 두 값 모두 후보 | 개발 단계 사용자 협의로 채택값 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §4 | 미결 — 사용자 결정 대기 (2026-09-03) | ## §2 확인사항 — 임도교본(2019) vs 사방교본(2023) 충돌 리스트 (2026-08-12, 최종검증에서 도출) diff --git a/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md b/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md new file mode 100644 index 00000000..1c366e5e --- /dev/null +++ b/resources/knowledge/technical_info/01_임도/03_계산정보/유토곡선_토량배분.md @@ -0,0 +1,94 @@ +--- +title: 유토곡선(토적도) 토량배분 — 평형선·장비 한계거리·표기 +category: 03_계산정보 +sources: + - doc: KDS 44 30 00 도로토공 (토량 배분·토적도) + version: 23.01 (현행) + loc: original/표준시방서/도로공사 표준시방서 (KCS 44 00 00)/첨부/[압축] 도로설계기준(KDS 44 00 00)/KDS 44 30 00 도로토공(23.01).md + - doc: 산림사업 표준품셈 (산림청고시 제2025-82호) + version: 2026.01.01 시행 + loc: original/행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md + - doc: 실무 설계 산출물 유토곡선 원본 (.MASS · .med · EARTH.DAT) 6개 노선 + version: 2024~2025년 설계분 + loc: original/실무문서/**/유토곡선.MASS +status: draft +last_updated: 2026-09-03 +--- + +관련 문서: [토량계산](토량계산.md) · [횡단면적](횡단면적.md) · [토공_수량](../04_수량분석정보/토공_수량.md) · [종단선형](../02_상세설계/종단선형.md) + +## 1. 현행 근거로 정해지는 것 + +| 항목 | 근거 | 내용 | +|---|---|---| +| 토량 배분 원칙 | KDS 44 30 00(23.01) 2.3.3 | 노선 전체 + **구간별 균형 배분**, **운반거리 최소화**. 토적도(Mass Curve)는 종방향 이동만 표시 | +| 절토 구분 | 같은 조 | 토사·리핑암·발파암으로 구분 산출 | +| 곡선의 기준 상태 | 품셈 1-2-3의 3 | 절토(자연) × C = 다짐 성토량. 곡선은 다짐상태 기준 | +| 시공계획고 | [종단선형 §4](../02_상세설계/종단선형.md) | 절·성토 균형 | + +## 2. 현행 근거가 없는 것 (공백 — 확정은 사용자 협의) + +법령·행정규칙·표준시방서·임도기술교본 어디에도 다음 세부 규칙은 없다. 실무 설계 프로그램이 +자체 로직으로 처리하는 영역이다. + +- **평형선(Balance Line) 자동 선정 규칙** — 어느 높이에 몇 개를 그을지. +- **극값 무시 임계** — 잔 진동을 블록으로 볼지 버릴지의 경계값. +- **사토·토취 물량 표기(balloon) 관행** — 표기 단위·위치·기재 항목. + +실무 도면(`유토곡선.dwg`) 은 압축 DWG 라 표기 문자열을 추출하지 못한다(2026-09-03 확인). +표기 관행은 도면 육안 확인이 필요하다. + +## 3. 실무 원본 관측 — 유토곡선 6개 노선 + +`유토곡선.MASS` = `측점, 누가거리(m), 누가토량(㎥)`. 같은 폴더 `.med` 는 같은 점 수에 좌표 +간격이 정확히 1/2 인 **작도용 축척 사본**이다. + +| 노선 | 연장(m) | 점 수 | 최종 누가토량 | 최소 | 최대 | 0선 교차 | 극값 수 | EARTH.DAT 한계거리 | +|---|---|---|---|---|---|---|---|---| +| 01.산불거창/거창북면본선 | 1,440 | 132 | **+156㎥** | −1,524 | 1,293 | 3 | 31 | 20.0/60.0 | +| 02.산불 장수/오솔길4차 | 2,000 | 171 | **+191㎥** | −916 | 1,872 | 9 | 44 | 20.0/60.0 | +| 03.산불진안/02.오솔길 | 1,900 | 161 | **+229㎥** | −824 | 2,663 | 15 | 31 | 20.0/60.0 | +| 04.산불 봉화/_오솔길(현동) | 3,465 | 329 | **+324㎥** | −2,544 | 2,116 | 17 | 63 | 20.0/60.0 | +| 05.동부청 영월/본선 | 2,680 | 224 | **+202㎥** | −2,836 | 3,736 | 7 | 39 | 20.0/60.0 | +| 05.동부청 영월/지선 | 520 | 38 | **+49㎥** | −359 | 1,788 | 5 | 12 | 20.0/60.0 | + +관측 사실: + +- **최종 누가토량이 전부 +49 ~ +324㎥** — 곡선 진폭(2,146~6,573㎥)의 1~7% 수준. 실무 설계는 + 노선 전체 절·성토를 균형에 맞춰 끝낸다(KDS 2.3.3 · 종단선형 §4와 일치). +- **0선을 3~17회 교차**하고 극값이 12~63개다 — 평형선·운반 블록이 노선 전체에 흩어진다. +- 측점 간격은 20m 고정이 아니라 2~20m 혼재다(지형 급변점 보조말뚝). + +## 4. 장비 한계거리 — 근거별 후보 + +| 출처 | 무대(종무대) | 도자(불도저) | 그 밖 | +|---|---|---|---| +| 실무 EARTH.DAT 헤더 6건 (전부 동일) | 20.0 m | **60.0 m** | 나머지 덤프 | +| Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` | 20.0 m | **70.0 m** | 나머지 덤프 | + +EARTH.DAT 첫 줄은 `C계수 1.0 1.0 1.0 무대한계 도자한계` 형식이며, C계수는 5건 0.9000 · +1건 0.9400 이다(품셈 L·C 표의 점질토·모래 계열 C 범위 안). + +현행 법령·품셈에는 **운반 장비별 한계거리 규정이 없다** — 위 두 값은 모두 후보이며 확정은 +사용자 협의 사항이다. (★ 근본 원칙 — 실무는 과거 데이터·참조용) + +## 5. 프로그램 대조 (2026-09-03 관측) + +용화 검증 프로젝트(연장 1,106m)의 유토곡선은 **최종 누가토량 −11,094㎥ · 0선 교차 0회**로, +위 실무 6건과 성격이 전혀 다르다(단조 하강). 그 결과 평형선·장비 띠·물량 balloon 이 생기지 +않는다 — 곡선이 0선을 넘지 않으면 블록이 하나로 뭉치기 때문이다. 절·성토 균형 판정과 조정은 +**사용자 몫**이며(2026-09-03 사용자 확정: 「설계프로그램은 알려만 주면 됨, 자동 조정 금지」), +프로그램은 불균형 수치를 화면에 드러내기만 한다. + +## 근거 + +| 항목 | 출처 | 위치 | +|---|---|---| +| 토량 배분·토적도 | KDS 44 30 00(23.01) 2.3.3 | 도로토공(23.01) md L130~142 | +| 절토 자연상태·L·C | 품셈 1-2-1 / 1-2-3의 3 | 품셈 md L541~572 / L703~735 | +| 실무 유토곡선 6건 | `original/실무문서/**/유토곡선.MASS` · `EARTH.DAT` | 2026-09-03 파싱 | + +## 미결 + +- 평형선 자동 선정·극값 무시 임계·balloon 표기 관행 → [03_미결_및_확인사항](../../../03_미결_및_확인사항.md) +- 장비 한계거리 도자 60m(실무) vs 70m(현행 구현) 채택 → 같은 문서 diff --git a/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md b/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md index 6ff44ed2..013e3837 100644 --- a/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md +++ b/resources/knowledge/technical_info/01_임도/03_계산정보/토량계산.md @@ -15,7 +15,7 @@ status: draft last_updated: 2026-08-17 --- -관련 문서: [횡단면적](횡단면적.md) · [절토_비탈면](../02_상세설계/절토_비탈면.md) · [성토_비탈면](../02_상세설계/성토_비탈면.md) · [유용토운반작업장](../02_상세설계/유용토운반작업장.md) · [토사채취장](../02_상세설계/토사채취장.md) · [토공_수량](../04_수량분석정보/토공_수량.md) +관련 문서: [유토곡선_토량배분](유토곡선_토량배분.md) · [횡단면적](횡단면적.md) · [절토_비탈면](../02_상세설계/절토_비탈면.md) · [성토_비탈면](../02_상세설계/성토_비탈면.md) · [유용토운반작업장](../02_상세설계/유용토운반작업장.md) · [토사채취장](../02_상세설계/토사채취장.md) · [토공_수량](../04_수량분석정보/토공_수량.md) ## 1. 체적 계산 방법 -- 2.54.0 From 56b9bf5280b57a4928c12bbdb433c642918267b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 08:17:21 +0900 Subject: [PATCH 016/441] =?UTF-8?q?feat(B03):=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20shapefile=20=EC=9D=98=20CSV=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=ED=99=94=20+=20LAS=20=EC=97=86=EB=8A=94=20=EB=8F=84?= =?UTF-8?q?=EC=97=BD=20=EC=84=9C=ED=94=BC=EC=8A=A4=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=EA=B3=84=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동설계 체인이 초기값 스냅샷에 `planned_route.csv` 를 함께 남김. 값은 체인이 이미 만든 정점(사업지 좌표계·트림·조밀화 후)이라 재계산 없음. - `load_design_route()` 는 surface_params 가 주어진 호출에 한해 그 CSV 정본을 읽음. 트림 전 원본이 필요한 호출(도엽 범위)은 종전 경로 유지. 지표면·노선이 바뀌면 `discard_initial_snapshot()` 이 폴더째 지워 경로가 저절로 닫힘. - `run_sheet_surface_analysis()` 가 노선을 원본 좌표계로 읽어 도엽 서피스를 딴 자리에 만들던 문제 교정 — `load_design_route()` 로 창구 통일, 도엽 확보 PRJ 탐색도 지형 PRJ 폴더로 교정. shapefile 노선(5179) + 지형 PRJ(5176) 조합에서 트림이 노선을 통째로 지우던 원인. 검증: tmp/tests 158 passed(신규 4건 포함), 공용 브라우저 실사용 경로로 용화 shapefile 한 벌 업로드 → 자동 체인 완주 → CSV 정본 643점, 원본 경로와 최대 좌표차 0.00007m. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Service_Chain.py | 2 +- .../B04_PreProcess_Engine_SheetSurface.py | 20 ++++++----- common_util/common_util_initial_snapshot.py | 35 +++++++++++++++++-- common_util/common_util_route_geometry.py | 17 +++++++++ 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index e1fb177d..55e24128 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -263,7 +263,7 @@ async def run_auto_design_chain( # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). try: async with pool.acquire() as connection: - await save_initial_snapshot(connection, project_root, route_id) + await save_initial_snapshot(connection, project_root, route_id, points) except Exception as exc: # noqa: BLE001 — 스냅샷 실패가 체인을 막지는 않는다 logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py index 6e3d1c3d..566c2c33 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetSurface.py @@ -510,7 +510,8 @@ def run_sheet_surface_analysis( 반환 형식은 `run_surface_analysis()`와 같다(save_surface_analysis_to_db 호환). """ - from common_util.common_util_route_geometry import read_planned_route + from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_route_geometry import load_design_route def _report(percent: int, stage: str, message: str) -> None: if on_progress is not None: @@ -522,10 +523,14 @@ def run_sheet_surface_analysis( processed_dir.mkdir(parents=True, exist_ok=True) models_dir.mkdir(parents=True, exist_ok=True) - planned = read_planned_route(route_csv_path) + # 노선을 **사업지(.prj) 좌표계로 옮긴 뒤** 도엽을 뜬다. 원본 좌표 그대로 뜨면 노선이 + # 5179, 지표면이 5176처럼 갈려 설계 계통(`load_design_route`)이 재투영한 노선이 지표면 + # 밖으로 나가고 트림이 노선을 통째로 지운다(2026-09-03 실측: shapefile 노선 + LAS 없는 + # 설계). LAS 경로·`build_sheet_surface_from_route`와 같은 창구를 쓰는 것이 요지다. + planned = load_design_route(project_root) if planned is None or len(planned.vertices) < 2: raise ValueError(f"계획 노선 파일을 읽지 못했습니다: {route_csv_path.name}") - epsg = planned.epsg or 5186 + crs = planned.crs_input or project_epsg_from_prj(project_root) route_xy = np.array([(v.x, v.y) for v in planned.vertices], dtype=np.float64) bounds_dict = { @@ -545,16 +550,15 @@ def run_sheet_surface_analysis( project_root, processed_dir, bounds_dict, - route_csv_path.parent, + # 노선 세트 폴더의 PRJ는 노선 좌표계다 — 지형 PRJ를 고르게 지형 폴더를 준다. + project_root / "B03_FileInput" / "input" / "prj", rebuild=False, - default_epsg=f"EPSG:{epsg}", + default_epsg=crs, report=_report, ) _report(70, "surface_model", "도엽등고선 3D 서피스 생성 중") - models = build_sheet_surface_model( - project_root, processed_dir, models_dir, route_xy, f"EPSG:{epsg}" - ) + models = build_sheet_surface_model(project_root, processed_dir, models_dir, route_xy, crs) if not models: raise ValueError("도엽등고선으로 지표면을 만들지 못했습니다 — 도엽 확보를 확인하세요.") diff --git a/common_util/common_util_initial_snapshot.py b/common_util/common_util_initial_snapshot.py index e9a56e5c..90eadb9c 100644 --- a/common_util/common_util_initial_snapshot.py +++ b/common_util/common_util_initial_snapshot.py @@ -9,6 +9,7 @@ CLAUDE.md 5장(조작·데이터 흐름 정책)의 **초기값** 층이다. 자 `pipe_points.json` 편집분이 그대로 남아 초기값과 다른 결과가 나온다(2026-08-29). """ +import csv import json import shutil from pathlib import Path @@ -19,6 +20,10 @@ import aiomysql # 스냅샷 폴더는 워크플로우 단계가 아니므로 PROJECT_STORAGE_LAYOUT_V2에 넣지 않는다. SNAPSHOT_DIRNAME = "initial_snapshot" _DB_DUMP_NAME = "db.json" +# 설계가 쓰는 계획노선 정본 — shapefile로 온 노선도 여기서는 CSV 한 벌이다. +# 사업지(.prj) 좌표계로 옮기고 지표면 밖을 잘라 조밀화까지 끝낸 값이라, 설계 계통은 +# 이 파일만 읽으면 매번 같은 노선을 본다(2026-09-03 사용자 확정). +DESIGN_ROUTE_CSV_NAME = "planned_route.csv" # 초기 설계 체인이 도는 동안만 존재하는 마커(진입 차단 판정용). DESIGNING_LOCK_NAME = "initial_design.lock" # 초기 설계 체인이 실패로 끝났음을 남기는 마커. @@ -40,6 +45,11 @@ def snapshot_dir(project_root: Path) -> Path: return Path(project_root) / SNAPSHOT_DIRNAME +def design_route_csv_path(project_root: Path) -> Path: + """설계용 계획노선 CSV 정본의 자리.""" + return snapshot_dir(Path(project_root)) / DESIGN_ROUTE_CSV_NAME + + def designing_lock_path(project_root: Path) -> Path: """초기 설계 체인이 도는 동안만 존재하는 마커. @@ -158,14 +168,24 @@ def _is_json_native(value: Any) -> bool: async def save_initial_snapshot( - connection: aiomysql.Connection, project_root: Path, route_id: int + connection: aiomysql.Connection, + project_root: Path, + route_id: int, + design_route_points: list[dict[str, float]] | None = None, ) -> None: - """자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다.""" + """자동설계 체인 성공 직후 한 번 부른다. 이미 있으면 덮어쓰지 않는다. + + `design_route_points`는 체인이 이미 만들어 둔 계획노선 정점(사업지 좌표계·트림·조밀화 + 후)이다. 받으면 CSV 정본으로 함께 남긴다 — 노선이 shapefile로 왔더라도 설계 계통이 + 읽는 것은 이 CSV 한 벌이다. + """ root = Path(project_root) target = snapshot_dir(root) if has_initial_snapshot(root): return target.mkdir(parents=True, exist_ok=True) + if design_route_points: + _write_design_route_csv(target / DESIGN_ROUTE_CSV_NAME, design_route_points) for tree in _FILE_TREES: _copy_tree(root / tree, target / tree.replace("/", "__")) @@ -181,6 +201,17 @@ async def save_initial_snapshot( (target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") +def _write_design_route_csv(path: Path, points: list[dict[str, float]]) -> None: + """계획노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것으로.""" + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows( + (index, round(point["x"], 4), round(point["y"], 4)) + for index, point in enumerate(points) + ) + + def wipe_edited_masters(project_root: Path) -> list[str]: """사용자 편집 정본을 걷어낸다 — 재계산으로 **진짜 초기값**을 만들기 위한 사전 정리. diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index 3dfd7032..eb02f0d2 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -227,12 +227,29 @@ def load_design_route( 주지 않으면 읽어서 좌표계만 맞춘 원본을 돌려준다. """ from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_initial_snapshot import design_route_csv_path from config.config_system import ( ROUTE_DIRECT_LINK_CELL_FACTOR, ROUTE_GRID_RES_M, ROUTE_PLANNED_DENSIFY_SAFETY, ) + # 체인이 남긴 CSV 정본이 있으면 그것이 설계 노선이다 — 좌표계 변환·트림·조밀화가 이미 + # 끝난 값이라 다시 하지 않는다(노선이 shapefile로 왔어도 여기서는 CSV 한 벌이다). + # 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로 + # 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 — + # surface_params 없음)은 여기를 타지 않는다. + if surface_params: + master = design_route_csv_path(project_root) + if master.is_file(): + stored = read_planned_route_csv(master) + if stored is not None and len(stored.vertices) >= 2: + return replace_vertices( + stored, + [(v.x, v.y) for v in stored.vertices], + crs_input=project_epsg_from_prj(project_root), + ) + route_file = find_planned_route_file(project_root / "B03_FileInput" / "input") planned = read_planned_route(route_file) if route_file else None if planned is None or len(planned.vertices) < 2: -- 2.54.0 From 23b1e7e5b0e5ff3479cbf123c880cf02ebafaa11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 09:10:46 +0900 Subject: [PATCH 017/441] =?UTF-8?q?feat(B05/B06):=20=EC=98=9B=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EA=B3=A0=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EC=B0=A8=EB=8B=A8=20+=20=EC=82=AC?= =?UTF-8?q?=EB=A9=B4=20=EB=AF=B8=EA=B5=90=EC=B0=A8=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 저장된 횡단이 지금 계획선과 어긋나면(재계산 예약 상태) 유토곡선을 그리지 않고 「횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다」만 띄움. 옛 계획고로 만든 면적이 2초간 보였다가 정본으로 갈아 끼워지던 것을 없앰(2026-09-03 사용자 확정 「새 값만 보여주기」). 용화 실측 — 65측점 중 56개가 낡음, 계획고 차 −9.36~+5.83m, 저장분 성토 42,050㎥ ↔ 정본 14,881㎥. ② 사면이 계산 반폭 끝까지 원지반과 만나지 않아 면적이 잘린 측점에 카드 경고 표기. 판정은 면적을 내는 엔진 한 곳(B06_Section_Engine_Design)에서 `slope_unclosed` 로 내려 화면·수량·도면이 같은 기준을 봄. 계산은 손대지 않음(2026-09-03 사용자 확정 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」). 검증 — 공용 브라우저 B06 실측: 2.6초 재계산 안내만 표시(옛 값 미노출), 4.4초 정본 표시, 사면 미교차 경고 13/65측점. pytest 366 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Profile_MassHaul.ts | 13 ++++++++++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 5 +++- B06_Section/B06_Section_Api_Types.ts | 6 +++++ B06_Section/B06_Section_Engine_Design.py | 12 +++++++++ B06_Section/B06_Section_Router_Design.py | 1 + B06_Section/B06_Section_UI_Cross_View.ts | 9 +++++++ B06_Section/B06_Section_UI_Section_View.ts | 26 ++++++++++++------- B06_Section/B06_Section_UI_Style_Cross.css | 6 +++++ ui_template/ui_template_locale_b2.ts | 13 ++++++++++ 9 files changed, 81 insertions(+), 10 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 456ab36d..b31ac9ce 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -124,6 +124,12 @@ export interface RouteMassHaulDrawParams { onSelectStation: (stationId: string) => void; /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ onClearSelection?: () => void; + /** + * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 + * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: + * 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다. + */ + pendingRecalc?: boolean; } export interface RouteMassHaulPanel { @@ -306,6 +312,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); return; } + // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 **옛 계획고로 만든 값**이다. + // 그걸 잠깐 그렸다가 재계산 결과로 갈아 끼우면 사용자가 옛 그림을 본다(2026-09-03 + // 사용자 확정: 「새 값만 보여주기」). Panel이 이미 재계산을 예약해 두므로 기다린다. + if (params.pendingRecalc) { + note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); + return; + } // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5f53b99d..f2f34eec 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -12,7 +12,7 @@ import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; -import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; +import { LONG_PAD, staleDesignChainages } 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 { @@ -326,6 +326,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, crossSections: detail.cross_sections, + // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — + // Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). + pendingRecalc: staleDesignChainages(detail).length > 0, axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 23d7e724..e0c0b13c 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -435,6 +435,12 @@ export interface CrossDesign { fill_area_m2: number; /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ fill_ground_slope?: number | null; + /** + * 사면이 샘플 범위(±계산 반폭) 끝에서도 원지반과 만나지 않은 측점. 면적이 거기서 + * 잘려 절·성토량이 실제와 다르다 — 계산은 그대로 두고 카드에 경고만 단다 + * (2026-09-03 사용자 확정: 「영원히 못 만나는 지형이 있을 수 있으니 경고로 대체」). + */ + slope_unclosed?: boolean; ditch_area_m2: number; design_line: Array<{ offset_m: number; elevation_m: number }>; /** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */ diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 669e1eb1..b54486ae 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -31,6 +31,10 @@ from config.config_system import ( ) +# 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다. +_SLOPE_CLOSE_TOLERANCE_M = 0.01 + + def _side_role(section_mode: str) -> tuple[str, str]: """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" if section_mode == "left_cut": @@ -533,6 +537,12 @@ def compute_cross_design( # 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지). cut_area, fill_area = _trapezoid_areas(offsets, diffs) fill_ground_slope = geometry.fill_ground_slope() + # 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼 + # 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는 + # 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정). + slope_unclosed = bool(diffs) and ( + abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M + ) # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. @@ -629,6 +639,8 @@ def compute_cross_design( "cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), + # 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용. + "slope_unclosed": slope_unclosed, # 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None. "fill_ground_slope": ( round(fill_ground_slope, 4) if fill_ground_slope is not None else None diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index d563bb15..9b67101e 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -29,6 +29,7 @@ PREVIEW_DESIGN_FIELDS = ( "fill_area_m2", "fill_ground_slope", "design_elevation_m", + "slope_unclosed", ) diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 36b0188a..941785d2 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -309,6 +309,15 @@ export function createCrossSectionCard( const meta = document.createElement("div"); meta.className = "b06-cross-card__meta"; + // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 + // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). + if (section.design?.slope_unclosed) { + const openSlope = document.createElement("span"); + openSlope.className = "b06-cross-card__warning"; + openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; + openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); + meta.append(openSlope); + } if (section.structure) { const structure = document.createElement("span"); structure.className = "b06-cross-card__structure"; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 515cee82..e3a56afd 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -84,6 +84,7 @@ import { L, longitudinalMaxChainage, LONG_PAD, + staleDesignChainages, type YScaleOptions, } from "./B06_Section_UI_Section_Common"; @@ -516,14 +517,19 @@ export function createSectionView( ), ]; - const series: MassHaulSeries[] = currentConversion - ? computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - currentConversion, - currentNaturalSpoilSlope, - ) - : []; + // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 **옛 계획고로 만든 값**이다. 진입 + // 직후 `reconcileStaleDesigns()`가 다시 계산해 갈아 끼우므로, 그 사이 옛 값으로 곡선을 + // 그리면 사용자가 옛 그림을 본다(2026-09-03 사용자 확정: 「새 값만 보여주기」). + const pendingRecalc = staleDesignChainages(detail).length > 0; + const series: MassHaulSeries[] = + currentConversion && !pendingRecalc + ? computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + currentConversion, + currentNaturalSpoilSlope, + ) + : []; // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); @@ -588,7 +594,9 @@ export function createSectionView( panelCount.textContent = ""; panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); } else { - panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); + panelCount.textContent = pendingRecalc + ? L("B06_MassHaul_Recalculating") + : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); } chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0a3d4b5c..ee013e19 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -232,6 +232,12 @@ flex: 0 0 auto; } +/* 사면 미교차 경고 — 면적이 계산 반폭에서 잘린 측점(2026-09-03). */ +.b06-cross-card__warning { + color: var(--color-warning); + white-space: nowrap; +} + /* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */ .b06-design__seg--header { flex: 0 0 auto; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 4d9e4080..c87995bd 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -341,6 +341,13 @@ export const ui_locales_b2 = { "측점에 지반유형을 지정하면 유토곡선이 표시됩니다.", "The mass haul diagram appears once ground types are assigned to stations.", ], + /* 사면이 계산 반폭(지반 샘플 범위) 끝에서도 원지반과 안 만난 측점 경고. + 계산은 그대로 두고 사실만 알린다(2026-09-03 사용자 확정). */ + B06_Cross_SlopeUnclosed: ["사면 미교차", "Slope not closed"], + B06_Cross_SlopeUnclosed_Tip: [ + "사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.", + "The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.", + ], B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"], B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"], B06_MassHaul_Fill: ["성토", "Fill"], @@ -356,6 +363,12 @@ export const ui_locales_b2 = { "노선 끝의 누가토량 — 0에 가까울수록 절·성토 균형. 곡선이 0선을 넘지 않으면 평형선·운반 블록이 생기지 않습니다.", "Cumulative volume at the end of the route — the closer to zero, the better cut/fill balance. No balance lines or haul blocks appear while the curve never crosses zero.", ], + /* 저장된 횡단이 옛 계획고로 굳어 있어 재계산을 기다리는 동안 — 옛 값으로 그린 곡선을 + 보여 주지 않는다(2026-09-03 사용자 확정: 「새 값만 보여주기」). */ + B06_MassHaul_Recalculating: [ + "횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.", + "Recalculating cross sections against the current profile.", + ], B06_MassHaul_AllHidden: [ "표시할 곡선을 하나 이상 켜 주세요.", "Turn on at least one curve to display.", -- 2.54.0 From 6bca5215166e5a4b3df6a5ef856235fdc09c73c2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 09:43:38 +0900 Subject: [PATCH 018/441] =?UTF-8?q?fix(B04):=20=EC=84=B8=EB=B6=80=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20=EB=B0=B0=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=A2=85=EB=8B=A8=20=EB=A7=88=EB=A3=A8=20=EB=B6=84?= =?UTF-8?q?=EC=88=98=EB=A0=B9=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 옛 방식은 한 칸 이웃만 보는 국소 하강이라 1m 간격 계획고의 미세 요철에 걸려 멈췄다. 용화 실측: 측점 2,138개 중 1,898개(88.8%)가 저점에 갇혀 흐름이 아니라 누가거리 최근접으로 관에 붙었고, 그 결과 도로 셀 43.2%가 자기보다 높은 관에 배정됐다 (최대 6.82m 오르막). 이웃한 두 관 사이의 최고점(마루)을 분수령으로 삼아 왼쪽은 앞 관, 오른쪽은 뒤 관이 받게 바꿈. 1차원에서 물은 마루를 넘지 못하므로 물리적으로 정확하고 미세 요철에 면역. 첫 관 앞·마지막 관 뒤는 그 관이 받는다. 관 순서는 호출자가 준 그대로 보존. 검증: 단위 5건 신규(전체 163 passed), 실측 오르막 배정 43.2% → 17.6%(최대 4.15m), 잔여 1,993셀은 전부 사그 저류 후 더 낮은 장벽으로 넘치는 경우(어긋남 0). Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_drainage_detail.py | 65 +++++++++++----------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index ceb52b29..21b90fbf 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -460,46 +460,43 @@ def assign_road_cells_to_pipes( ) -> np.ndarray: """도로 셀마다 물이 실제로 흘러가는 담당 관 번호를 정한다. - 노면 물은 측구를 타고 종단 내리막으로 흐르므로, 종단 계획선을 1차원 지형으로 보고 - 같은 방식(내리막 추적 + 관에서 흡수)으로 푼다. 관이 없는 사그(저점)에 갇힌 구간은 - 가장 가까운 관이 받는 것으로 본다. + 노면 물은 측구를 타고 종단 내리막으로 흐르므로 종단 계획선을 1차원 지형으로 본다. + 1차원에서는 물이 **마루(구간 최고점)를 넘지 못한다** — 이웃한 두 관 사이의 최고점이 + 곧 분수령이고, 그 왼쪽은 앞 관이, 오른쪽은 뒤 관이 받는다. 첫 관 앞과 마지막 관 뒤는 + 그 관이 받는다. + + 옛 방식(한 칸 이웃만 보는 국소 하강 + 관 없는 저점은 최근접 관)은 계획고의 미세 + 요철에 걸려 멈췄다. 용화 실측: 측점 2,138개 중 1,898개(88.8%)가 저점에 갇혀 흐름이 + 아니라 **누가거리 최근접**으로 배정됐고, 그 결과 도로 셀 43.2%가 자기보다 높은 관에 + 배정됐다(최대 6.82m 오르막). 마루 기준은 미세 요철을 타지 않으므로 오르막 배정이 + 구조적으로 생기지 않는다(2026-09-03 사용자 확정). """ total_length = vertices[-1].chainage_m step = max(DRAINAGE_DITCH_SAMPLE_M, 0.5) stations = np.arange(0.0, total_length + step, step) - heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) pipe_chainages = np.array([pipe.chainage_m for pipe in pipes]) - pipe_station = np.clip(np.round(pipe_chainages / step).astype(np.int64), 0, stations.size - 1) - - # 앞뒤 이웃 중 더 낮은 쪽으로 흘려보낸다(양쪽 다 높으면 사그 = 제자리). - back_z = np.full(stations.size, np.inf) - back_z[1:] = heights[:-1] - forward_z = np.full(stations.size, np.inf) - forward_z[:-1] = heights[1:] - go_back = (back_z < heights) & (back_z <= forward_z) - go_forward = (forward_z < heights) & ~go_back - receiver = np.arange(stations.size, dtype=np.int64) - receiver[go_back] -= 1 - receiver[go_forward] += 1 - receiver[pipe_station] = pipe_station # 관은 물을 흡수한다 - - owner = np.full(stations.size, -1, dtype=np.int64) - owner[pipe_station] = np.arange(pipe_chainages.size) - jump = receiver - for _ in range(40): - next_jump = jump[jump] - if np.array_equal(next_jump, jump): - break - jump = next_jump - resolved = owner[jump] - # 관 없는 사그에 갇힌 구간은 가장 가까운 관에 붙인다. - orphan = resolved < 0 - if orphan.any() and pipe_chainages.size: - nearest = np.abs(stations[orphan, None] - pipe_chainages[None, :]).argmin(axis=1) - resolved[orphan] = nearest - slot_station = np.clip(np.round(road_chainage / step).astype(np.int64), 0, stations.size - 1) - return resolved[slot_station].astype(np.int32) + if pipe_chainages.size == 0: + return np.full(road_chainage.size, -1, dtype=np.int32) + + heights = np.array([interpolate_vertex(vertices, float(s))[2] for s in stations]) + # 관 순서는 호출자가 준 그대로 돌려줘야 한다 — 누가거리로 정렬해 풀고 끝에 되돌린다. + order = np.argsort(pipe_chainages, kind="stable") + pipe_station = np.clip( + np.round(pipe_chainages[order] / step).astype(np.int64), 0, stations.size - 1 + ) + + owner = np.full(stations.size, pipe_station.size - 1, dtype=np.int64) # 마지막 관 뒤 + owner[: pipe_station[0] + 1] = 0 # 첫 관 앞 + for index in range(pipe_station.size - 1): + left = pipe_station[index] + right = pipe_station[index + 1] + if right <= left: + continue + ridge = left + int(np.argmax(heights[left : right + 1])) + owner[left : ridge + 1] = index + owner[ridge + 1 : right + 1] = index + 1 + return order[owner][slot_station].astype(np.int32) # ── ⑩ 세부유역 조립 ──────────────────────────────────────────────────────── -- 2.54.0 From 9b4abecf9dd5cdbd6d12055dbcc9e062f2ec980b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 09:50:14 +0900 Subject: [PATCH 019/441] =?UTF-8?q?refactor(B05/B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=9D=BC=EC=9B=90=ED=99=94=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B4=EB=8A=94=20=EB=85=B8=EC=84=A0=C2=B7=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=EC=B0=BD=EA=B5=AC=C2=B7?= =?UTF-8?q?=EB=82=A1=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 데이터를 두 화면에 보여 주는 기능인데 세 곳이 갈려 값이 달랐다(실측: 절토(자연) B06 3,704.6㎥ ↔ B05 4,526.8㎥). ① 보는 노선 — B05는 최신 경로, B06은 최신 **확정** 경로를 열어 노선을 다시 탐색한 프로젝트에서 서로 다른 노선을 봤다(route 126 DRAFT ↔ 125 CONFIRMED, 같은 20m 측점 성토 4.82㎡ ↔ 85.9㎡). `get_workflow_route_context()`(최신 경로) 신설해 B06 화면 context가 그것을 쓴다. 납품 도면(B07)이 쓰는 확정 전용 창구는 그대로 둔다. ② 횡단 재계산 — 호출이 두 벌이라 인자가 갈렸다(B06만 표준 단면값·암 경계 오프셋 전달, 보존하는 사용자 부속값도 2개 ↔ 7개). `B06_Section_Cross_Refresh.refreshCrossDesigns()` 한 창구로 모으고 세션 편집값은 저장소에서 직접 읽어 패널 없는 B05도 같은 값을 보낸다. ③ 낡음 판정 — 「옛 암 2단계 필드 누락」 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었다. 공용 `staleDesignChainages()` 안으로 옮겨 두 화면이 같은 시점에 같은 조치를 한다. 검증 — 공용 브라우저 실측: 두 화면 모두 route 126, 요약줄 문자열 완전 일치 (`절토(자연) 4,526.8㎥ · 성토 14,881.8㎥ · 토취 10,213.3㎥ · 최종 누가토량 −10,213.3㎥`). pytest 370 passed·17 skipped(일원화 검사 4건 신설), typecheck·prettier·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Preview.ts | 37 +++----- B06_Section/B06_Section_Cross_Refresh.ts | 93 +++++++++++++++++++ B06_Section/B06_Section_Repository.py | 31 ++++++- B06_Section/B06_Section_Router.py | 6 +- B06_Section/B06_Section_UI_Page.ts | 52 +++-------- B06_Section/B06_Section_UI_Page_Persist.ts | 22 +++++ B06_Section/B06_Section_UI_Section_Common.ts | 15 ++- B06_Section/B06_Section_UI_Standard_Panel.ts | 11 ++- 8 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 B06_Section/B06_Section_Cross_Refresh.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Preview.ts b/B05_Profile/B05_Profile_UI_Profile_Preview.ts index 5434af36..b6c74daa 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Preview.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Preview.ts @@ -10,7 +10,7 @@ * ========================================================================== */ import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment"; -import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch"; +import { refreshCrossDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; export interface CrossPreviewContext { @@ -43,29 +43,20 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview { const routeId = ctx.routeId(); if (!detail || routeId === null) return; const current = (seq += 1); - // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 - // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면 - // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. - void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, { - fullDesigns: true, + // 재계산은 B06과 **같은 창구**를 쓴다 — 표준 단면값·암 경계 오프셋이 빠지면 서버가 + // 다른 설계를 그려 같은 데이터가 두 화면에서 다른 값이 된다(2026-09-03 일원화). + const isCurrent = (): boolean => + current === seq && ctx.detail() === detail && ctx.routeId() === routeId; + void refreshCrossDesigns({ + projectId: ctx.projectId, + routeId, + detail, + edits: ctx.edits(), + // 늦게 온 옛 응답이 새 설계를 덮지 않게 반영 직전에 한 번 더 확인한다. + shouldApply: isCurrent, }) - .then((next) => { - const live = ctx.detail(); - if (current !== seq || !live || ctx.routeId() !== routeId) return; - const designByChainage = new Map( - next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of live.cross_sections) { - const full = designByChainage.get(section.chainage_m.toFixed(3)); - if (!full || !section.design) continue; - // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. - section.design = { - ...(full as NonNullable), - inlet_structure: section.design.inlet_structure, - basin_adjust: section.design.basin_adjust, - }; - } - ctx.onApplied(); + .then((updated) => { + if (updated.length) ctx.onApplied(); }) .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts new file mode 100644 index 00000000..95021b69 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B06_Section_Cross_Refresh.ts + * 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다. + * + * ── 왜 필요한가 (2026-09-03 사용자 지시) ───────────────────────────── + * 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이 + * 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`. + * 원인은 인자였다: + * · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })` + * · B05: `previewCrossDesigns(..., undefined, { fullDesigns })` + * 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다. + * 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이 + * 조정 같은 값이 사라졌다. + * + * 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은 + * 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다. + * ========================================================================== */ + +import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel"; + +/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ +export interface CrossRefreshEdits { + station_offsets: Record; + curve_radii: Record; +} + +export interface CrossRefreshInput { + projectId: string; + routeId: number; + /** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */ + detail: SectionDetailResponse; + edits: CrossRefreshEdits; + /** + * 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다). + * 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다. + */ + shouldApply?: () => boolean; +} + +/** 서버 설계로 갈아 끼워도 **살려 두는 사용자 부속값** — 화면 조작으로만 생기는 값이다. */ +function preserveUserFields( + next: NonNullable, + previous: CrossSection["design"], +): NonNullable { + if (!previous) return next; + return { + ...next, + inlet_structure: previous.inlet_structure, + basin_adjust: previous.basin_adjust, + revet_adjust: previous.revet_adjust, + extra_wall_counts: previous.extra_wall_counts, + extra_spans: previous.extra_spans, + revet_link_detached: previous.revet_link_detached, + revet_follow_grade: previous.revet_follow_grade, + }; +} + +/** + * 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다. + * 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다. + */ +export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const { projectId, routeId, detail, edits, shouldApply } = input; + // full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다. + const response = await previewCrossDesigns( + projectId, + routeId, + edits, + readStandardCrossSession(projectId) ?? undefined, + { + fullDesigns: true, + rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + }, + ); + if (shouldApply && !shouldApply()) return []; + const designByChainage = new Map( + response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), + ); + const updated: number[] = []; + for (const section of detail.cross_sections) { + const next = designByChainage.get(section.chainage_m.toFixed(3)); + if (!next) continue; + section.design = preserveUserFields( + next as NonNullable, + section.design, + ); + updated.push(section.chainage_m); + } + return updated; +} diff --git a/B06_Section/B06_Section_Repository.py b/B06_Section/B06_Section_Repository.py index f5f175d9..72807284 100644 --- a/B06_Section/B06_Section_Repository.py +++ b/B06_Section/B06_Section_Repository.py @@ -25,17 +25,18 @@ def _validate_stage_path(relative_path: str) -> str: return normalized.as_posix() -async def get_confirmed_route_context( - connection: aiomysql.Connection, project_id: UUID +async def _route_context( + connection: aiomysql.Connection, project_id: UUID, confirmed_only: bool ) -> dict[str, Any] | None: - """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다. + """경로 하나와 연결된 지표면 좌표계를 조회한다(정렬은 최신 우선). surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가 없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다. """ + status_filter = "AND r.status = 'CONFIRMED'" if confirmed_only else "" async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( - """ + f""" SELECT r.id AS route_id, COALESCE( sm.crs_epsg, @@ -47,7 +48,7 @@ async def get_confirmed_route_context( ) AS crs_epsg FROM routes r LEFT JOIN surface_models sm ON sm.id = r.surface_model_id - WHERE r.project_id = %s AND r.status = 'CONFIRMED' + WHERE r.project_id = %s {status_filter} ORDER BY r.computed_at DESC, r.id DESC LIMIT 1 """, @@ -62,6 +63,26 @@ async def get_confirmed_route_context( } +async def get_confirmed_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """최신 **확정** 경로 — 납품 도면(B07)처럼 확정본만 봐야 하는 곳이 쓴다.""" + return await _route_context(connection, project_id, confirmed_only=True) + + +async def get_workflow_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """워크플로 화면이 보는 경로 = **최신 경로**(확정 여부 무관). + + B05는 `get_latest_route()`로 최신 경로를 열고, B06은 확정 경로만 열어서 노선을 다시 + 탐색한 프로젝트에서 두 화면이 **다른 노선**을 봤다(2026-09-03 실측: B05 route 126 + DRAFT / B06 route 125 CONFIRMED — 같은 20m 측점의 성토가 4.82㎡ ↔ 85.9㎡). 같은 + 데이터를 두 창으로 보여 주는 구조이므로 경로 선택 규칙을 최신 경로 하나로 맞춘다. + """ + return await _route_context(connection, project_id, confirmed_only=False) + + async def get_latest_section_options( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 0ce22413..c939680c 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -26,7 +26,7 @@ from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, delete_sections_for_route, - get_confirmed_route_context, + get_workflow_route_context, get_cross_section_designs, get_latest_section_options, get_longitudinal_section, @@ -104,7 +104,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON pool = get_db_pool() try: async with pool.acquire() as connection: - route_context = await get_confirmed_route_context(connection, project_id) + # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 + # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). + route_context = await get_workflow_route_context(connection, project_id) surface_params = await get_surface_confirmation_params(connection, str(project_id)) # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. async with connection.cursor() as cursor: diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 7568e7b3..bccbab34 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -15,15 +15,16 @@ import { computeCrossDesign, fetchSectionContext, getSections, - previewCrossDesigns, type SectionContextResponse, type SectionDetailResponse, type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; +import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { confirmCurrentSections, createRockBoundaryStore, + rockBoundarySessionKey, saveCurrentSections, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; @@ -284,16 +285,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { */ async function reconcileStaleDesigns(): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; - // 계획고 어긋남은 B05와 **같은 규칙**으로 판정한다(공용 staleDesignChainages). - // 옛 암 2단계 필드 누락은 B06 전용 조건이라 여기서 더한다. - const staleByPlan = new Set(staleDesignChainages(sectionDetail)); - const stale = sectionDetail.cross_sections.filter((section) => { - const design = section.design; - if (!design) return false; - if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; - return staleByPlan.has(section.chainage_m); - }); - if (!stale.length) return; + // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 + + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. + if (!staleDesignChainages(sectionDetail).length) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -309,33 +303,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { station_offsets: alignment?.edits?.station_offsets ?? {}, curve_radii: alignment?.edits?.curve_radii ?? {}, }; - const response = await previewCrossDesigns( + // 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서 + // 다른 값이 된다(2026-09-03 사용자 지시로 일원화). + const updated = await refreshCrossDesigns({ projectId, - currentRouteId, + routeId: currentRouteId, + detail: sectionDetail, edits, - standardPanel?.getValues(), - { fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) }, - ); - const designByChainage = new Map( - response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), - ); - for (const section of sectionDetail.cross_sections) { - const next = designByChainage.get(section.chainage_m.toFixed(3)); - if (next) { - // full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다. - section.design = { - ...(next as NonNullable), - inlet_structure: section.design?.inlet_structure, - basin_adjust: section.design?.basin_adjust, - revet_adjust: section.design?.revet_adjust, - extra_wall_counts: section.design?.extra_wall_counts, - extra_spans: section.design?.extra_spans, - revet_link_detached: section.design?.revet_link_detached, - revet_follow_grade: section.design?.revet_follow_grade, - }; - sectionView.refreshCard(section.chainage_m); - } - } + }); + for (const chainageM of updated) sectionView.refreshCard(chainageM); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`${L("B06_Design_Failed")}${detail}`, "error"); @@ -399,7 +375,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ sessionKey: () => - projectId && currentRouteId !== null ? `b06:rockb:${projectId}:${currentRouteId}` : null, + projectId && currentRouteId !== null + ? rockBoundarySessionKey(projectId, currentRouteId) + : null, detail: () => sectionDetail, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 765edcfa..1117d351 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -25,6 +25,28 @@ import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; +/** + * 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다. + * (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.) + */ +export function rockBoundarySessionKey(projectId: string, routeId: number): string { + return `b06:rockb:${projectId}:${routeId}`; +} + +/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */ +export function readRockBoundarySession( + projectId: string, + routeId: number, +): Record { + try { + const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId)); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index a6416965..9f8016fb 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -81,16 +81,27 @@ export function validElevation( export function staleDesignChainages( detail: { longitudinal: { design_profiles?: DesignProfile[] }; - cross_sections: Array<{ chainage_m: number; design?: { design_elevation_m: number } | null }>; + cross_sections: Array<{ + chainage_m: number; + design?: { + design_elevation_m: number; + geometry_preset?: string; + two_stage_slope?: boolean; + } | null; + }>; }, toleranceM = 1e-3, ): number[] { const profiles = detail.longitudinal.design_profiles; - if (!profiles?.length) return []; return detail.cross_sections .filter((section) => { const design = section.design; if (!design) return false; + // 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다. + // 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의 + // 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다. + if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true; + if (!profiles?.length) return false; const planned = designElevationAt(profiles, section.chainage_m); return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM; }) diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 1f22ce0c..c1d39870 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -52,8 +52,13 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection; } -/** 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. */ -function readSession(projectId: string): StandardCrossSection | null { +/** + * 세션에 저장된 편집값을 읽는다. 없거나 손상 시 null. + * + * 패널 밖에서도 필요하다 — 횡단 재계산 단일 창구(`B06_Section_Cross_Refresh`)가 B05처럼 + * 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다. + */ +export function readStandardCrossSession(projectId: string): StandardCrossSection | null { try { const raw = window.sessionStorage.getItem(sessionKey(projectId)); return raw ? (JSON.parse(raw) as StandardCrossSection) : null; @@ -170,7 +175,7 @@ export function createStandardPanel( onApplyAll?: () => void | Promise, ): StandardPanelController { // 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다. - const sessionValue = readSession(projectId); + const sessionValue = readStandardCrossSession(projectId); const hadSession = sessionValue !== null; const state: StandardCrossSection = sessionValue ?? cloneDefaults(defaults); -- 2.54.0 From 16a24ace3d1a424c3b14f1f3b1fc7c3397892205 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 10:02:15 +0900 Subject: [PATCH 020/441] =?UTF-8?q?feat(B04):=20=EC=A7=80=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=203D=20=EB=B7=B0=EC=96=B4=EC=97=90=20=EB=B0=A9?= =?UTF-8?q?=EC=9C=84=20=EC=BD=A4=ED=8C=8C=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뷰어 좌표 규약이 `x, 높이, -y` 라 세계 북쪽은 -z 다. 카메라 오프셋(카메라 − 타깃)의 수평 성분으로 도북이 화면에서 몇 도 돌아갔는지 계산해(atan2(offset.x, offset.z)) 바늘을 돌린다. 뷰어 우하단 — 축척 막대(좌하단)의 반대편에 둔다. 뷰어 파일이 이미 946줄이라 콤파스는 별도 모듈로 분리(700줄 제한). 애니메이션 루프에서 0.5° 이상 움직였을 때만 transform 을 다시 쓴다. 검증: typecheck 통과. 공용 브라우저 실측 — [상단] 시점 0°(바늘이 북쪽=화면 위), [사시도] 41.6°, 캔버스 220px 드래그 시 218.6° 로 카메라와 함께 회전. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_UI_Compass.ts | 52 +++++++++++++++++++ B04_PreProcess/B04_PreProcess_UI_Style.css | 38 ++++++++++++++ .../B04_PreProcess_UI_TerrainViewer.ts | 8 +++ 3 files changed, 98 insertions(+) create mode 100644 B04_PreProcess/B04_PreProcess_UI_Compass.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_Compass.ts b/B04_PreProcess/B04_PreProcess_UI_Compass.ts new file mode 100644 index 00000000..92c98b5d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Compass.ts @@ -0,0 +1,52 @@ +/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다. + * + * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은 + * **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만 + * 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`. + * (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.) + * + * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). + */ + +export interface TerrainCompass { + root: HTMLElement; + /** 카메라 오프셋(카메라 위치 − 타깃)으로 바늘 각도를 맞춘다. */ + update(offsetX: number, offsetZ: number): void; + setVisible(visible: boolean): void; +} + +const NEEDLE_SVG = ` +`; + +export function createTerrainCompass(): TerrainCompass { + const root = document.createElement("div"); + root.className = "b04-surface__compass"; + root.hidden = true; + root.title = "도북(N)"; + + const dial = document.createElement("div"); + dial.className = "b04-surface__compass-dial"; + dial.innerHTML = NEEDLE_SVG; + root.append(dial); + + // 프레임마다 style을 다시 쓰지 않도록 직전 각도를 들고 있는다. + let lastHeading = Number.NaN; + + return { + root, + update(offsetX: number, offsetZ: number): void { + const heading = (Math.atan2(offsetX, offsetZ) * 180) / Math.PI; + if (Number.isFinite(lastHeading) && Math.abs(heading - lastHeading) < 0.5) return; + lastHeading = heading; + dial.style.transform = `rotate(${heading.toFixed(1)}deg)`; + }, + setVisible(visible: boolean): void { + root.hidden = !visible; + }, + }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 0ee38188..0659e84c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -557,6 +557,44 @@ text-shadow: 0 1px 2px var(--color-surface-raised); } +/* 방위 콤파스 — 3D 뷰어 우하단. 축척 막대(좌하단)와 짝. */ +.b04-surface__compass { + position: absolute; + right: var(--spacing-16); + bottom: var(--spacing-16); + z-index: 2; + pointer-events: none; + filter: drop-shadow(0 1px 2px var(--color-surface-raised)); +} + +.b04-surface__compass-dial { + transform-origin: 50% 50%; + transition: transform 80ms linear; +} + +.b04-surface__compass-ring { + fill: var(--color-surface-raised); + fill-opacity: 0.75; + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b04-surface__compass-north { + fill: var(--color-danger, #d64545); +} + +.b04-surface__compass-south { + fill: var(--color-text-secondary); +} + +.b04-surface__compass-letter { + fill: var(--color-text-body); + font-family: var(--font-body); + font-size: 9px; + font-weight: var(--font-weight-semibold); + text-anchor: middle; +} + /* --- 하단 2D 지도 --- */ .b04-map { --b04-map-vector: var(--color-accent); diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 75b7c565..01d7e64a 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -7,6 +7,7 @@ import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. +import { createTerrainCompass } from "./B04_PreProcess_UI_Compass"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { @@ -166,6 +167,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { scaleBar.append(scaleLabel); viewerArea.append(scaleBar); + // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. + const compass = createTerrainCompass(); + viewerArea.append(compass.root); + // Elevation bounds legend bar overlay (I-403) const legendBar = document.createElement("div"); legendBar.style.position = "absolute"; @@ -794,6 +799,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { // Render scale bar dynamically if (terrainMesh && terrainMesh.visible) { scaleBar.hidden = false; + compass.setVisible(true); + compass.update(camera.position.x - controls.target.x, camera.position.z - controls.target.z); const dist = camera.position.distanceTo(controls.target); const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight); const roughMeters = 100 * metersPerPixel; @@ -803,6 +810,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`; } else { scaleBar.hidden = true; + compass.setVisible(false); } // 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비). -- 2.54.0 From d140d04766961a3a356e6b0031144e3b038dbd45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 10:29:09 +0900 Subject: [PATCH 021/441] =?UTF-8?q?fix(B03):=20[LAS=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84]=20=ED=86=A0=EA=B8=80=EC=9D=B4=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B2=84=ED=8A=BC=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 토글은 LAS 카드의 필수 여부를 바꾸는데(isSlotRequired) change 핸들러가 updateUploadButton() 을 부르지 않아, 파일을 먼저 고르고 토글을 나중에 켜면 필수 조건이 채워졌는데도 [파일 업로드] 가 잠긴 채 남았다. 새로고침하거나 파일을 하나 더 골라야 풀렸다. 검증: 공용 브라우저에서 저장된 토글 표시를 지워 첫 진입 상태로 재현 — 파일 7개만 고른 상태는 비활성(안내: LAS/LAZ 필요), 토글을 켠 직후 즉시 활성, 다시 끄면 즉시 비활성. typecheck 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 9ed8384c..d00be854 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -797,6 +797,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } applyLasFreeState(); pageError.textContent = ""; + // 이 토글이 LAS 카드의 필수 여부를 바꾼다 — 버튼 판정을 다시 돌리지 않으면 파일을 다 + // 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고 + // 토글을 나중에 켠 순서에서 재현). + updateUploadButton(); }); // LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다. -- 2.54.0 From a7f6232c012d88bafc7952ec6378690f7655662b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 10:32:20 +0900 Subject: [PATCH 022/441] =?UTF-8?q?fix(B06):=20I=ED=98=95=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B4=80=20=ED=95=98=EB=8B=A8=EC=9D=84=20?= =?UTF-8?q?=EB=B2=BD=EB=A9=B4=20=EC=95=88=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=E2=80=94=20=EA=B4=80=EC=9D=B4=20=EB=B2=BD=EC=9D=84?= =?UTF-8?q?=20=EA=B4=80=ED=86=B5=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관 하단 자리는 벽 계류측 변과 원지반의 교점인데, 원지반이 벽 상단보다 높은 자리에서는 교점이 벽 상단으로 밀려 관이 벽 위에 얹혔다(측벽과 배관이 만나지 않음). ㄴ·ㄷ형은 관이 바닥판 윗면에서 시작해 벽 안에 물리는 것과 대비된다. 교점을 벽 상단에서 관경+여유(0.5m)만큼 아래로 제한한다. 원지반이 그보다 낮으면 종전대로 교점을 그대로 쓴다. 벽을 키우는 방향은 벽이 1:0.3으로 기울어 base가 계류측으로 밀리며 지반 교점이 함께 올라가 벽이 7.5m까지 자라 폐기했다. 검증 — 공용 브라우저 B06 I형 8개 단면 실측: 관 유입측 끝단과 벽의 세로 겹침 18.1~56.4px(수정 전 0 — 벽 위로 이탈). ㄴ형 대조(13+4.1을 조정창에서 ㄴ형으로 바꿔 확인) 겹침 유지, 확인 후 세션값 원복. pytest 370 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Basin.ts | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index d789323a..744c21cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -174,21 +174,15 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }; // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 // 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥. - const wallBase = anchor.offset + outward * (lean * wallHeight); + const wallBaseAt = (height: number): number => anchor.offset + outward * (lean * height); // I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼 // 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지. - const iWallBottom = - shape === "I" - ? Math.min(anchor.elevation, groundAt(wallBase), groundAt(wallBase + outward * memberT)) - - floorThickness - : defaultBottom; - const parts: BasinLayout["parts"] = [ - { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, - ]; - const iWallPoints = parts[0].points; - const iGroundIntersection = (): OffsetPoint => { - const low = iWallPoints[3]; - const high = iWallPoints[2]; + const iBottomAt = (base: number): number => + Math.min(anchor.elevation, groundAt(base), groundAt(base + outward * memberT)) - floorThickness; + /** 벽 계류측 변(하단-전면 → 상단-전면)과 원지반의 교점 — I형 관 하단 자리. */ + const groundOnFace = (points: OffsetPoint[]): OffsetPoint => { + const low = points[3]; + const high = points[2]; let lo = 0; let hi = 1; for (let pass = 0; pass < 32; pass += 1) { @@ -206,6 +200,34 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { elevation: low.elevation + (high.elevation - low.elevation) * t, }; }; + const wallBase = wallBaseAt(wallHeight); + const iWallBottom = shape === "I" ? iBottomAt(wallBase) : defaultBottom; + const parts: BasinLayout["parts"] = [ + { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, + ]; + const iWallPoints = parts[0].points; + /** 벽 계류측 변 위에서 그 표고에 해당하는 점 — 관 하단을 벽면을 따라 내릴 때 쓴다. */ + const pointOnFaceAt = (elevation: number): OffsetPoint => { + const low = iWallPoints[3]; + const high = iWallPoints[2]; + const span = high.elevation - low.elevation; + const t = span > 1e-9 ? Math.min(Math.max((elevation - low.elevation) / span, 0), 1) : 0; + return { + offset: low.offset + (high.offset - low.offset) * t, + elevation: low.elevation + span * t, + }; + }; + /** + * I형 관 하단 자리 = 벽 계류측 변과 원지반의 교점. 다만 **관이 벽을 관통해야** 하므로 + * (ㄴ·ㄷ형은 바닥판 위에서 이미 물린다) 벽 상단에서 관경+여유만큼 아래로 제한한다. + * 원지반이 벽 상단보다 높은 자리에서 제한이 없으면 관이 벽 위로 떠 벽과 만나지 않았다 + * (2026-09-03 사용자 지적: 「측벽과 배관의 형상이 교차하고 있어야 함」). + */ + const iGroundIntersection = (): OffsetPoint => { + const hit = groundOnFace(iWallPoints); + const invertCeiling = anchor.elevation + wallHeight - (diameterM + REVET_FREEBOARD_M); + return hit.elevation <= invertCeiling ? hit : pointOnFaceAt(invertCeiling); + }; // ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다 // (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. const wallOuterAt = (elevation: number): number => { -- 2.54.0 From f1bcee7857a44ca9fdaf6640f7bf3441db05a547 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 12:40:21 +0900 Subject: [PATCH 023/441] =?UTF-8?q?feat(B04):=20=EC=84=B8=EB=B6=80?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=A7=81=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D(=EC=99=B8=EA=B3=BD+=EA=B5=AC=EB=A9=8D)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=93=ED=98=80=20=EC=A4=91=EC=B2=A9?= =?UTF-8?q?=C2=B7=EB=B9=88=EA=B3=B5=EA=B0=84=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단일 링 자료형이 곧 중첩·빈공간이었다. 아래 유역이 위 유역을 감싸면 구멍이 사라져 위 유역을 통째로 덮고(합성 실측 256㎡ 전량), 한 관의 유역이 두 조각이면 작은 쪽이 사라져 빈공간이 됐다(144㎡). - `polygon_parts()` 신설 — 조각마다 [외곽 링, 구멍 링...], 넓은 조각부터. - `WatershedBasin.boundary_parts` 로 교체. `boundary_xy`(가장 넓은 조각의 외곽)와 `boundary_rings`(편 링 목록)는 파생 속성이라 옛 소비처는 그대로 동작한다. - API 에 `polygon_rings_lonlat` 추가. 저장 GeoJSON 은 구멍을 가진 Polygon, 조각이 여럿이면 MultiPolygon. - 캔버스 4곳(B04 유역화면 채움·선택, B05 배수유역도 채움·선택)은 even-odd 로 한 번에 채우고 `pointInRings()` 로 판정 — 구멍 안을 눌러도 바깥 유역이 잡히지 않는다. 중복이던 지역 `pointInRing` 은 삭제하고 공용 것으로 통일. 검증: 단위 5건 신규(도넛 구멍 보존·조각 2개 보존·파생 속성·빈 경계·조립 경로 전체에서 링 2개), 전체 168 passed, typecheck 통과. 브라우저 — API 가 유역 22개에 링 목록을 실어 보내고, 캔버스 even-odd 실측(가운데 알파 0 / 고리 255)으로 구멍이 실제로 뚫린다. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 3 ++ .../B04_PreProcess_Engine_Watershed_Flow.py | 19 ++++++++ .../B04_PreProcess_Router_Basins.py | 21 +++++++-- B04_PreProcess/B04_PreProcess_UI_Basins.ts | 35 +++++++-------- .../B04_PreProcess_UI_MapOverlays.ts | 43 ++++++++++++++++--- .../B05_Profile_UI_Drainage_Interact.ts | 9 ++-- B05_Profile/B05_Profile_UI_Drainage_Render.ts | 2 +- common_util/common_util_drainage_detail.py | 19 ++++++-- 8 files changed, 116 insertions(+), 35 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 47e7a6d9..df279155 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -500,7 +500,10 @@ export interface DetailBasin { index: number; chainage_m: number; outlet_lonlat: [number, number]; + /** 가장 넓은 조각의 외곽 링 하나 — 중심 계산처럼 링 하나면 되는 자리에 쓴다. */ polygon_lonlat: Array<[number, number]>; + /** 조각·구멍을 모두 편 링 목록. 도넛 유역과 떨어진 조각을 그대로 그린다(even-odd). */ + polygon_rings_lonlat?: Array>; area_m2: number; relief_m: number; flow_length_m: number; diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py index a03b7521..216cfaf6 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Flow.py @@ -560,6 +560,25 @@ def polygonize_labels( return merged +def polygon_parts(geometry: Polygon | MultiPolygon) -> list[list[list[tuple[float, float]]]]: + """폴리곤을 **조각 목록**으로 편다 — 조각마다 [외곽 링, 구멍 링...] 순. + + `largest_ring()`은 가장 큰 조각의 외곽 하나만 낸다. 세부유역에서는 그 자리가 곧 + 중첩·빈공간이었다(2026-09-03 합성 실측): 아래 유역이 위 유역을 감싸면 구멍이 사라져 + 위 유역 256㎡가 통째로 덮이고, 한 관의 유역이 두 조각(225㎡+144㎡)이면 작은 144㎡가 + 빠져 빈공간이 됐다. 조각은 넓은 것부터, 좌표는 조각 안에서 외곽 다음에 구멍이다. + """ + if geometry.is_empty: + return [] + parts = list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + result: list[list[list[tuple[float, float]]]] = [] + for part in sorted(parts, key=lambda item: item.area, reverse=True): + rings = [[(float(x), float(y)) for x, y in part.exterior.coords]] + rings.extend([(float(x), float(y)) for x, y in hole.coords] for hole in part.interiors) + result.append(rings) + return result + + def largest_ring(geometry: Polygon | MultiPolygon) -> list[tuple[float, float]]: """폴리곤(또는 멀티폴리곤)에서 가장 큰 조각의 외곽 링 좌표를 뽑는다.""" if geometry.is_empty: diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index cb696cc1..86edeea1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -161,7 +161,12 @@ def _payload( "index": basin.index, "chainage_m": round(basin.chainage_m, 2), "outlet_lonlat": list(to_lonlat(basin.outlet_x, basin.outlet_y)), + # 옛 소비처를 위한 외곽 링 하나. 그리기는 아래 링 목록을 쓴다. "polygon_lonlat": [list(to_lonlat(x, y)) for x, y in basin.boundary_xy], + # 조각·구멍을 모두 편 링 목록 — 도넛 유역과 떨어진 조각을 그대로 그린다. + "polygon_rings_lonlat": [ + [list(to_lonlat(x, y)) for x, y in ring] for ring in basin.boundary_rings + ], "area_m2": round(basin.area_m2, 1), "relief_m": round(basin.relief_m, 2), "flow_length_m": round(basin.flow_length_m, 1), @@ -192,9 +197,19 @@ def _basin_features( to_lonlat = context.to_lonlat features: list[dict[str, Any]] = [] for basin in detail.basins: - ring = [list(to_lonlat(x, y)) for x, y in basin.boundary_xy] - if len(ring) < 4: + # GeoJSON 규격 그대로 — 조각마다 [외곽, 구멍...], 조각이 여럿이면 MultiPolygon. + parts = [ + [[list(to_lonlat(x, y)) for x, y in ring] for ring in part if len(ring) >= 4] + for part in basin.boundary_parts + ] + parts = [part for part in parts if part] + if not parts: continue + geometry = ( + {"type": "Polygon", "coordinates": parts[0]} + if len(parts) == 1 + else {"type": "MultiPolygon", "coordinates": parts} + ) features.append( { "type": "Feature", @@ -213,7 +228,7 @@ def _basin_features( "design_flow_m3s": basin.design_flow_m3s, "bridge_required": basin.bridge_required, }, - "geometry": {"type": "Polygon", "coordinates": [ring]}, + "geometry": geometry, } ) for pipe, point in zip(detail.pipes, points): diff --git a/B04_PreProcess/B04_PreProcess_UI_Basins.ts b/B04_PreProcess/B04_PreProcess_UI_Basins.ts index 716806e6..251369cf 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Basins.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Basins.ts @@ -15,6 +15,7 @@ * 확정 전에 화면을 떠나면 저장된 값으로 되돌아온다. * ========================================================================== */ +import { pointInRings } from "./B04_PreProcess_UI_MapOverlays"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; @@ -71,17 +72,6 @@ const BASIN_ALPHA_PLAIN = 0.22; const BASIN_ALPHA_SELECTED = 0.38; const BASIN_ALPHA_MUTED = 0.06; -/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */ -function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let inside = false; - for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { - const [xi, yi] = ring[index]; - const [xj, yj] = ring[previous]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; - } - return inside; -} - interface PipeMarker { chainage: number; source: PipeSource; @@ -289,10 +279,11 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl let smallest = Number.POSITIVE_INFINITY; basins.forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; - const ring = basin.polygon_lonlat.map(([lon, lat]) => - lonLatToScreen(normalizerRef as Normalizer, view, lon, lat), + // 구멍 안(= 안에 든 다른 유역)을 누르면 바깥 유역이 잡히지 않는다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) => + ring.map(([lon, lat]) => lonLatToScreen(normalizerRef as Normalizer, view, lon, lat)), ); - if (!pointInRing(ring, x, y)) return; + if (!pointInRings(rings, x, y)) return; if (basin.area_m2 < smallest) { smallest = basin.area_m2; hit = basin.index; @@ -508,12 +499,18 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl const ring = basin.polygon_lonlat.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat), ); + // 조각·구멍을 한 경로에 담아 even-odd로 채운다 — 구멍이 실제로 뚫린다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((part) => + part.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)), + ); context.beginPath(); - ring.forEach(([px, py], order) => { - if (order === 0) context.moveTo(px, py); - else context.lineTo(px, py); + rings.forEach((part) => { + part.forEach(([px, py], order) => { + if (order === 0) context.moveTo(px, py); + else context.lineTo(px, py); + }); + context.closePath(); }); - context.closePath(); // 하나를 고르면 나머지는 옅게 물러난다 — 고른 유역의 경계를 눈으로 좇을 수 있게. const muted = selectedBasin !== null && selectedBasin !== basin.index; const alpha = muted @@ -522,7 +519,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl ? BASIN_ALPHA_SELECTED : BASIN_ALPHA_PLAIN; context.fillStyle = basinColor(index, alpha); - context.fill(); + context.fill("evenodd"); context.strokeStyle = basinColor(index, muted ? 0.3 : 0.95); context.lineWidth = selectedBasin === basin.index ? 2.8 : 1.8; context.stroke(); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 4b9867a1..3fa47f41 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -19,6 +19,11 @@ import { export type FilledRing = { ring: ReadonlyArray; + /** + * 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** — + * 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만. + */ + rings?: ReadonlyArray>; /** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */ label?: string; }; @@ -35,19 +40,27 @@ export function drawFilledRing( color: string, ): void { if (entry.ring.length < 3) return; + const rings = entry.rings?.length ? entry.rings : [entry.ring]; let sumX = 0; let sumY = 0; context.beginPath(); - entry.ring.forEach(([lon, lat], index) => { + rings.forEach((ring) => { + if (ring.length < 3) return; + ring.forEach(([lon, lat], index) => { + const [x, y] = lonLatToScreen(normalizer, view, lon, lat); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.closePath(); + }); + // 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다. + entry.ring.forEach(([lon, lat]) => { const [x, y] = lonLatToScreen(normalizer, view, lon, lat); sumX += x; sumY += y; - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); }); - context.closePath(); context.fillStyle = color; - context.fill(); + context.fill("evenodd"); context.strokeStyle = color; context.lineWidth = 1.6; context.stroke(); @@ -87,6 +100,26 @@ export function drawRingBadge( } /** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */ +/** + * 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정. + * 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다. + */ +export function pointInRings( + rings: ReadonlyArray>, + x: number, + y: number, +): boolean { + let inside = false; + for (const ring of rings) { + for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) { + const [xi, yi] = ring[index]; + const [xj, yj] = ring[previous]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + } + return inside; +} + export function ringCenterOnScreen( ring: ReadonlyArray, normalizer: Normalizer, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 9d49e317..6212e2d1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -14,7 +14,7 @@ import { type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { pointInRing } from "./B05_Profile_UI_Drainage_Parts"; +import { pointInRings } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import type { createMapContextMenu } from "@ui/ui_template_context_menu"; @@ -123,10 +123,11 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { let smallest = Number.POSITIVE_INFINITY; params.getBasins().forEach((basin) => { if (basin.polygon_lonlat.length < 3) return; - const ring = basin.polygon_lonlat.map(([lon, lat]) => - lonLatToScreen(normalizer, view, lon, lat), + // 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않도록 링 전체로 판정한다. + const rings = (basin.polygon_rings_lonlat ?? [basin.polygon_lonlat]).map((ring) => + ring.map(([lon, lat]) => lonLatToScreen(normalizer, view, lon, lat)), ); - if (!pointInRing(ring, x, y)) return; + if (!pointInRings(rings, x, y)) return; // 겹치면 면적이 작은 쪽을 고른다(안쪽 조각 우선). if (basin.area_m2 < smallest) { smallest = basin.area_m2; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 9df429ae..015898e2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -88,7 +88,7 @@ export function drawDrainageScene( } drawFilledRing( context, - { ring: basin.polygon_lonlat }, + { ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat }, normalizer, view, // 하나를 고르면 나머지는 옅게 물러난다. diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 21b90fbf..592b756f 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -34,7 +34,7 @@ import numpy as np from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_hotspots from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES -from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import largest_ring, polygonize_labels +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import polygon_parts, polygonize_labels from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_BOX, @@ -104,7 +104,10 @@ class WatershedBasin: chainage_m: float outlet_x: float outlet_y: float - boundary_xy: list[tuple[float, float]] = field(default_factory=list) + # 유역 경계 — 조각마다 [외곽 링, 구멍 링...]. 도넛(아래 유역이 위 유역을 감싼 경우)과 + # 떨어진 조각을 그대로 싣는다. 단일 링만 쓰던 시절에는 이 둘이 소실돼 화면에서 중첩· + # 빈공간으로 보였다(2026-09-03). + boundary_parts: list[list[list[tuple[float, float]]]] = field(default_factory=list) area_m2: float = 0.0 relief_m: float = 0.0 flow_length_m: float = 0.0 @@ -123,6 +126,16 @@ class WatershedBasin: recommended_facility: str = "pipe" recommended_diameter_mm: int | None = None + @property + def boundary_xy(self) -> list[tuple[float, float]]: + """가장 넓은 조각의 외곽 링 — 링 하나만 받는 옛 소비처를 위한 자리.""" + return self.boundary_parts[0][0] if self.boundary_parts else [] + + @property + def boundary_rings(self) -> list[list[tuple[float, float]]]: + """조각 구분 없이 편 링 목록 — 캔버스는 even-odd로 한 번에 채운다.""" + return [ring for part in self.boundary_parts for ring in part] + @dataclass class RoadRouting: @@ -537,7 +550,7 @@ def assemble_basins( chainage_m=pipe.chainage_m, outlet_x=pipe.x, outlet_y=pipe.y, - boundary_xy=largest_ring(geometry) if geometry is not None else [], + boundary_parts=polygon_parts(geometry) if geometry is not None else [], area_m2=area, relief_m=relief, flow_length_m=flow_length, -- 2.54.0 From 8393dfd6193e70d82e99dffa0e8d7bf90381eda6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:13:00 +0900 Subject: [PATCH 024/441] =?UTF-8?q?fix(B04):=20=EC=84=B8=EB=B6=80=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=B5=9C=EC=86=8C=EB=A9=B4=EC=A0=81=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EC=9E=91=EC=9D=80?= =?UTF-8?q?=20=EC=9C=A0=EC=97=AD=EC=9D=B4=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=95=88=20=EA=B7=B8=EB=A0=A4=EC=A7=80=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100㎡ 미만 유역은 `polygonize_labels` 의 최소면적 필터에 걸려 폴리곤이 아예 만들어지지 않았다. 유역 목록에는 있는데 지도에는 없는 상태 — 실측에서 용화 22개 중 1개(64㎡), S자 48개 중 1개가 그랬다. 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없다. 실측: 폴리곤 면적 오차(셀 면적 대비 평균) 용화 5.76%→1.86%, S자 3.56%→1.42%, 경계 없는 유역 1개→0개. 좌표점은 583→622 · 806→829 로 거의 그대로다. 1차 전체 배수유역 등 다른 호출부의 기본값(100㎡)은 그대로 둔다. S자 실사례 검증: 용화 지형 위에 스위치백 3구간 합성 노선(정점 439·연장 4,528m)으로 신규 프로젝트를 만들어 자동 체인까지 완주 — 관 48개·세부유역 48개, 경계 없는 유역 0개. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_drainage_detail.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 592b756f..1b9413bb 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -527,7 +527,11 @@ def assemble_basins( reached = routing.road_slot >= 0 labels[reached] = pipe_of_slot[routing.road_slot[reached]] - polygons = polygonize_labels(spec, labels) + # 최소면적 필터를 끈다 — 그 필터가 곧 빈공간이었다. 떨어진 조각을 100㎡ 미만이라고 + # 버리면 유역 면적과 그림이 어긋난다(실측: 용화 5.76%→1.86%, S자 3.56%→1.42%, + # 조각 유역 0→2·0→3 복원). 링 목록이 조각을 싣게 된 뒤로는 버릴 이유가 없고, 좌표점은 + # 583→622·806→829로 거의 늘지 않는다(2026-09-03). 남은 오차는 simplify(2.0m) 몫. + polygons = polygonize_labels(spec, labels, min_area_m2=0.0) cell_area = spec.cell_area_m2 basins: list[WatershedBasin] = [] for order, pipe in enumerate(pipes): -- 2.54.0 From e83171b2b35988619d417e3489fffa76c87fa412 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:25:20 +0900 Subject: [PATCH 025/441] =?UTF-8?q?refactor(=EA=B3=B5=ED=86=B5):=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A2=8C=ED=91=9C=EA=B3=84=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=EC=9D=84=20=EC=B0=BD=EA=B5=AC=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9=20+=20=ED=8A=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=A7=84=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 폴백 사다리가 네 곳에 서로 다른 모양으로 흩어져 있었고, 그중 배수유역 라우터 `_route_center_lonlat` 은 노선 CSV 의 `crs_epsg` **라벨**을 그대로 변환에 썼다(라벨과 실좌표계가 다른 사례 실측 — 2026-09-01 용화 라벨 5179 / 실제 5176). `common_util_crs.resolve_project_crs()` 신설 — 사다리는 ① 노선 crs_input ② 파일 라벨 (원본 좌표를 읽는 자리만) ③ 지형 PRJ(작업 좌표계 정본) ④ DB epsg ⑤ EPSG:5186. 작업 좌표계는 **지형 PRJ 우선으로 확정**(2026-09-03 사용자 결정 — 서피스 격자가 모델좌표의 주인이라 현행 유지). `project_epsg_from_prj()` 도 이 창구로 위임. 더해 지표면 트림이 노선을 통째로 지울 때 노선·지표면 bbox 를 함께 로그에 남긴다 — "겹치지 않습니다" 만으로는 좌표계 문제인지 측량 범위 문제인지 갈리지 않았다. 검증 — `tmp/tests/test_project_crs_resolution.py` 5건 신설(사다리 4·진단 1), 전체 375 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Engine_Extent.py | 10 ++-- .../B04_PreProcess_Router_Inflow.py | 7 +-- .../B04_PreProcess_Router_Watershed.py | 11 ++++- common_util/common_util_crs.py | 48 +++++++++++++++++++ common_util/common_util_drainage_context.py | 5 +- common_util/common_util_route_geometry.py | 33 +++++++++++++ 6 files changed, 98 insertions(+), 16 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Engine_Extent.py b/B04_PreProcess/B04_PreProcess_Engine_Extent.py index 2e5e931e..98865116 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Extent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Extent.py @@ -147,15 +147,11 @@ def project_epsg_from_prj(project_root: Path) -> str: """프로젝트 PRJ에서 좌표계를 읽는다. 없으면 중부원점(EPSG:5186). PRJ가 둘 이상 올라오므로(노선 세트 + 지형) 지형 PRJ를 고른다 — `find_project_prj`. + 판정은 작업 좌표계 창구(`resolve_project_crs`) 하나로 모았다(2026-09-03). """ - from common_util.common_util_crs import find_project_prj + from common_util.common_util_crs import resolve_project_crs - from .B04_PreProcess_Engine_VWorld import get_epsg_from_prj - - prj_path = find_project_prj(project_root) - if prj_path is not None: - return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) - return "EPSG:5186" + return resolve_project_crs(project_root) def map_meta_covers(meta_path: Path, extent: dict[str, list[float]]) -> bool: diff --git a/B04_PreProcess/B04_PreProcess_Router_Inflow.py b/B04_PreProcess/B04_PreProcess_Router_Inflow.py index 9d72165a..98ba38f6 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Inflow.py +++ b/B04_PreProcess/B04_PreProcess_Router_Inflow.py @@ -77,19 +77,16 @@ async def _resolve_epsg(project_id: UUID, stored_path: str) -> str: `crs_epsg` 열은 표시용 라벨이라 그 값을 쓰면 좌표가 딴 곳으로 간다 (2026-09-01 실측: 라벨 5179, 실제 5176 — 유입 폴리곤이 1,500km 밖에 찍혔다). """ - from B04_PreProcess.B04_PreProcess_Engine_Extent import project_epsg_from_prj + from common_util.common_util_crs import resolve_project_crs project_root = Path(resolve_stored_project_path(stored_path)) planned = load_design_route(project_root) if planned is not None and planned.crs_input: return planned.crs_input - prj_crs = project_epsg_from_prj(project_root) - if prj_crs: - return prj_crs pool = get_db_pool() async with pool.acquire() as connection: epsg = await get_surface_crs_epsg(connection, project_id, 0) - return f"EPSG:{epsg or 5186}" + return resolve_project_crs(project_root, db_epsg=epsg) def _collect_inflow( diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index c5bf9233..c6faa675 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -42,6 +42,7 @@ from common_util.common_util_route_geometry import ( load_design_route, read_planned_route, ) +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_wamis_rainfall import ( @@ -190,7 +191,7 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: ) # 설계 계통과 같은 프로젝트 좌표계로 맞춘다. 도엽 재투영도 이 좌표계로 간다. - source_crs = planned.crs_input or f"EPSG:{epsg or 5186}" + source_crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=epsg) to_lonlat_transformer = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True) to_metric_transformer = Transformer.from_crs("EPSG:4326", source_crs, always_xy=True) @@ -257,7 +258,13 @@ def _route_center_lonlat(stored_path: str, fallback_epsg: int | None) -> tuple[f if planned is None or not planned.vertices: return None middle = planned.vertices[len(planned.vertices) // 2] - source_crs = f"EPSG:{planned.epsg or fallback_epsg or 5186}" + # 여기 좌표는 **원본 파일 그대로**(트림 전)라 파일이 밝힌 좌표계로 읽는다 — 창구 사다리. + source_crs = resolve_project_crs( + Path(resolve_stored_project_path(stored_path)), + route_crs_input=planned.crs_input, + file_label_epsg=planned.epsg, + db_epsg=fallback_epsg, + ) lon, lat = Transformer.from_crs(source_crs, "EPSG:4326", always_xy=True).transform( middle.x, middle.y ) diff --git a/common_util/common_util_crs.py b/common_util/common_util_crs.py index fb56efbe..ac511ef8 100644 --- a/common_util/common_util_crs.py +++ b/common_util/common_util_crs.py @@ -209,3 +209,51 @@ def crs_input_from_prj(prj_text: str) -> str | None: else: logger.warning("PRJ 좌표계의 EPSG 라벨을 찾지 못함 — 파일 WKT로 변환: %s", crs.name) return text + + +def project_prj_crs(project_root: Path) -> str | None: + """프로젝트 **작업 좌표계** — 지형 PRJ 원문. PRJ가 없으면 None. + + 작업 좌표계는 지형 PRJ 로 확정(2026-09-03 사용자 결정, 종전 동작 유지). 서피스 격자가 + 모델좌표의 주인이고 노선은 `load_design_route()` 가 이 좌표계로 옮겨 오기 때문이다. + 노선 shapefile 은 다른 좌표계로 들어온다(실측 2026-08-31 — 노선 UTM-K / 지형 동부원점). + + 기본값을 섞지 않는다 — 사다리(`resolve_project_crs`)가 다음 근거로 내려갈 수 있어야 한다. + """ + prj_path = find_project_prj(project_root) + if prj_path is None: + return None + from B04_PreProcess.B04_PreProcess_Engine_VWorld import get_epsg_from_prj + + return get_epsg_from_prj(prj_path.read_text(encoding="utf-8", errors="ignore")) + + +def resolve_project_crs( + project_root: Path, + *, + route_crs_input: str | None = None, + file_label_epsg: int | None = None, + db_epsg: int | None = None, +) -> str: + """작업 좌표계를 정하는 **단일 창구** — pyproj 입력 문자열(`EPSG:n` 또는 WKT 원문). + + 사다리(위가 이김): + ① `load_design_route()` 가 돌려준 노선 `crs_input` — 이미 작업 좌표계로 맞춰 둔 값 + ② `file_label_epsg` — **원본 파일 좌표를 그대로 해석할 때만** 넘긴다(노선 CSV 의 + `crs_epsg` 열). 그 자리에서는 파일이 스스로 밝힌 좌표계가 유일한 근거다. + ③ 지형 PRJ(`project_prj_crs`) — 작업 좌표계 정본 + ④ DB `surface_models.crs_epsg` + ⑤ 중부원점 `EPSG:5186` — 최후 폴백 + + **변환이 끝난 좌표를 다룰 때는 ②를 넘기지 않는다.** 그 라벨은 실좌표계와 다른 사례가 + 실측됐다(2026-09-01 용화 — 라벨 5179 / 실제 5176). 이 창구를 두기 전에는 같은 사다리가 + 네 곳에 서로 다른 모양으로 흩어져 있었다. + """ + if route_crs_input: + return route_crs_input + if file_label_epsg: + return f"EPSG:{file_label_epsg}" + prj_crs = project_prj_crs(project_root) + if prj_crs: + return prj_crs + return f"EPSG:{db_epsg or 5186}" diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index bb71200f..2d023038 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -33,6 +33,7 @@ from common_util.common_util_route_geometry import ( load_design_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler @@ -99,8 +100,8 @@ async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | Non ) # 노선을 실제로 담고 있는 좌표계를 쓴다 — `load_design_route()`가 .prj 좌표계로 - # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다. - crs = planned.crs_input or f"EPSG:{planned.epsg or db_epsg or 5186}" + # 재투영하며 `crs_input`만 갱신하고 `epsg` 라벨은 CSV 값 그대로 남긴다(라벨은 안 씀). + crs = resolve_project_crs(project_root, route_crs_input=planned.crs_input, db_epsg=db_epsg) transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True) return ( DrainageContext( diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index eb02f0d2..b467425e 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -277,7 +277,10 @@ def load_design_route( except (FileNotFoundError, KeyError, OSError) as exc: logger.warning("설계 노선: 지표면을 열지 못해 트림을 건너뜁니다 — %s", exc) else: + before = points points = trim_route_to_surface(points, sampler) + if len(points) < 2: + _log_trim_wipeout(before, sampler, target_crs) points = densify_route( points, ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, @@ -308,6 +311,36 @@ def replace_vertices( ) +def _log_trim_wipeout(points: list[tuple[float, float]], sampler: Any, target_crs: str) -> None: + """트림이 노선을 통째로 지운 이유를 **수치로** 남긴다. + + 노선과 지표면의 좌표계가 어긋나면 트림 결과가 빈 목록이 되는데, 그때 로그가 + "겹치지 않습니다" 뿐이라 좌표계 문제인지 범위 문제인지 갈리지 않았다(2026-09-03 실사고: + 도엽 서피스가 노선 원본 좌표계로 만들어져 노선이 통째로 지워짐). 두 bbox 를 함께 찍어 + 한 줄로 판별되게 한다 — 겹침이 0이면 좌표계, 일부 겹치면 측량 범위 문제다. + """ + xs = [x for x, _ in points] + ys = [y for _, y in points] + grid_x = getattr(sampler, "x", None) + grid_y = getattr(sampler, "y", None) + surface = ( + f"[{float(min(grid_x)):.0f}~{float(max(grid_x)):.0f}, " + f"{float(min(grid_y)):.0f}~{float(max(grid_y)):.0f}]" + if grid_x is not None and grid_y is not None and len(grid_x) and len(grid_y) + else "미상" + ) + logger.warning( + "설계 노선: 지표면 트림 결과가 비었습니다 — 노선 bbox [%.0f~%.0f, %.0f~%.0f] / " + "지표면 bbox %s (작업 좌표계 %s). 두 범위가 전혀 안 겹치면 좌표계 불일치입니다.", + min(xs), + max(xs), + min(ys), + max(ys), + surface, + target_crs, + ) + + def trim_route_to_surface( points: list[tuple[float, float]], sampler: Any, -- 2.54.0 From 2aaa0c594dd5a8e2740abc00833ff6c9569e37af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:29:29 +0900 Subject: [PATCH 026/441] =?UTF-8?q?feat(B05):=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=EC=A0=91=EA=B7=BC=EC=84=A0=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=EC=9D=84=203D=20=ED=8C=A8=EC=B9=98=20=EB=A6=AC=EB=B3=B8?= =?UTF-8?q?=EC=97=90=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노견 → 벽 상단 접근선 구간이 패치에서 빠져 있어 좁은 좌측 패치 사이로 원지반이 드러나 보였다(2026-09-03 사용자 확정: 「패치로 덮기」). 접근선은 벽보다 위라 덮어도 관 구멍·L자 통로를 막지 않는다 — 벽 전면·유로는 종전대로 리본에 담지 않는다(2026-08-25 사용자 ②). 접근선 줄을 `patchPoints` 앞에 담고 `fillRuns` 에 한 장으로 실어 「구간 1줄 = 리본 1장」 규칙을 그대로 따른다. 검증 — typecheck·prettier 통과. 3D 육안 확인은 카메라 조작이 필요해 다음 화면 검증 묶음에서 함께 수행. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Corridor_Station_Structure.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index c96fb326..e0c6913f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -176,12 +176,18 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { points.push({ offset_m: side.top.offset, elevation_m: side.top.elevation }); } points.push({ offset_m: wall.points[2].offset, elevation_m: wall.points[2].elevation }); - // 패치 리본(잘린 자리 다시 그리기)은 **성토부선만** 담는다 — 벽 전면·유로를 - // 리본으로 덮으면 관 구멍과 L자 통로가 막힌다(2026-08-25 사용자 ②). 벽·바닥은 - // 구조물 솔리드가 그린다. + // 패치 리본(잘린 자리 다시 그리기)은 **성토부선과 접근선까지** 담는다 — 벽 전면·유로는 + // 여전히 담지 않는다(리본으로 덮으면 관 구멍과 L자 통로가 막힌다, 2026-08-25 사용자 ②). + // 접근선(노견 → 벽 상단)은 벽보다 위라 덮어도 구멍을 막지 않는데, 이 구간을 비워 두니 + // 좁은 좌측 패치 사이로 원지반이 드러나 보였다(2026-09-03 사용자 확정: 「패치로 덮기」). const patchPoints: OffsetPoint[] = []; // 성토 구간은 **구간별로도** 담는다 — 패치 리본이 한 장씩 낸다(2026-08-27 사용자). const fillRuns: OffsetPoint[][] = []; + const approachRun = points.slice(); + if (approachRun.length >= 2) { + patchPoints.push(...approachRun); + fillRuns.push(approachRun); + } // 유출측 다단과 달리 세월교는 단 나눔이 없다 — 접지 후 절토선(kind="cut")은 뺀다. const fill = side.fillSegments.filter((segment) => segment.kind !== "cut"); let kind: "cut" | "fill" = "fill"; -- 2.54.0 From ce4fcc1f13cadf472dcaef2d20bfe582789f0e76 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:37:05 +0900 Subject: [PATCH 027/441] =?UTF-8?q?feat(B07):=20=ED=86=A0=EC=A0=81?= =?UTF-8?q?=EB=8F=84=20=EA=B8=B8=EC=9D=B4=EB=B3=84=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B6=95=EC=B2=99=20+=20=EB=82=A9=ED=92=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=EA=B8=B0=208=ED=96=89=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 축척 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다(2026-09-03 사용자 결정). 후보는 도면 관행 축척(1:500~1:6,000, `DRAWING_SCALE_MASSHAUL_H_CANDIDATES`)이고 A1 작도영역 가용 폭 700mm 를 기준으로 판정한다. 실측 — 40m 노선 1:500 · 용화 1,106m 1:2,000 · 3,465m 1:5,000. 세로는 종이 1mm=50㎥ 고정(도면끼리 비교하려면 같아야 한다). 표기 8행(사용자 확정) — ① 장비명 무대→종무대 ② 값 표기 `Q= 162.41M3`(등호 뒤 한 칸) ③ 지시선 계단형·balloon 모서리 접점 ④ 평형선·띠 경계현 빨강 ⑤ 측점 `120+ 0.0` ⑥ 표 눈금 신설(정규 빨강·추가 회색) ⑦ 행 이름 3.2mm·자간 벌림 ⑧ M.N 소수 2자리 `0+16.90`. `MM_H` 상수를 없애고 `mm_h` 를 그리기 함수에 넘긴다 — 축척이 노선마다 달라졌기 때문. 검증 — `tmp/tests/test_b07_masshaul_basin.py` 갱신·신설(축척 3건·표기 8행·지시선·눈금), 전체 378 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Engine_Cad_MassHaul.py | 193 ++++++++++++------ config/config_system.py | 19 +- 2 files changed, 143 insertions(+), 69 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index e9b37c2c..899f52b1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -13,7 +13,8 @@ (정의처 `common_util_mass_haul.massHaulPayload`)을 그대로 읽어 좌표만 종이 mm로 바꾼다 — 곡선 보간·토량 배분 로직을 파이썬에 복제하지 않는다. -좌표 규약: x = 누가거리(m) x MM_H, y = 누가토량(㎥) / 종이 1mm당 토량. +좌표 규약: x = 누가거리(m) x mm_h, y = 누가토량(㎥) / 종이 1mm당 토량. +가로 축척(mm_h)은 노선 연장으로 정한다 — `auto_scale_h()`. """ import math @@ -32,7 +33,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, infer_station_interval, polyline_entity, - station_no_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, @@ -40,22 +40,45 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( scale_fields, ) from config.config_system import ( + DRAWING_MASSHAUL_USABLE_WIDTH_MM, DRAWING_SCALE_MASSHAUL_H, + DRAWING_SCALE_MASSHAUL_H_CANDIDATES, DRAWING_SCALE_MASSHAUL_V_M3_MM, ) -# 도면 좌표 = 종이 mm. 실거리 1 m가 종이에서 차지하는 mm (1/2,000 -> 0.5). -MM_H = 1000.0 / DRAWING_SCALE_MASSHAUL_H -# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). +# 토량 1㎥가 종이에서 차지하는 mm (1 mm = 50㎥ -> 0.02). 세로는 고정이다 — 축척 분모가 +# 아니라 종이 1 mm 가 받는 토량이라 도면끼리 비교하려면 같아야 한다. MM_V = 1.0 / DRAWING_SCALE_MASSHAUL_V_M3_MM + +def auto_scale_h(length_m: float) -> int: + """노선 연장에 맞는 가로 축척 분모 — **한 장에 들어가는 가장 큰 그림**을 고른다. + + 2026-09-03 사용자 결정(길이별 자동 축척). 후보는 도면 관행 축척뿐이고, 가장 큰 후보로도 + 안 들어가면 그 값을 쓴다(도각 템플릿이 콘텐츠에 맞춰 늘어나므로 잘리지는 않는다). + """ + if length_m <= 0: + return DRAWING_SCALE_MASSHAUL_H + for denominator in sorted(DRAWING_SCALE_MASSHAUL_H_CANDIDATES): + if length_m * 1000.0 / denominator <= DRAWING_MASSHAUL_USABLE_WIDTH_MM: + return denominator + return max(DRAWING_SCALE_MASSHAUL_H_CANDIDATES) + + CURVE_LAYER_ID = "b08-masshaul-curve" CURVE_COLOR = "#ff66ff" AXIS_LAYER_ID = "b08-masshaul-axis" AXIS_COLOR = "#ff4d4d" BAND_LAYER_ID = "b08-masshaul-band" BAND_COLOR = "#ffe066" -BALANCE_COLOR = "#e8edf4" +# 평형선·띠 경계현은 **빨강** — 납품 도면 표기(2026-09-03 사용자 확정). balloon·문자는 +# 종전 노랑 그대로다. +BALANCE_COLOR = "#ff4d4d" +BAND_CHORD_COLOR = "#ff4d4d" +# 표 눈금: 정규 측점은 빨강, 그 사이 추가 측점은 회색(납품 도면 표기). +TABLE_TICK_COLOR = "#ff4d4d" +TABLE_TICK_EXTRA_COLOR = "#9aa5a0" +_TABLE_TICK_LEN = 2.0 TABLE_LAYER_ID = "b08-masshaul-table" # 세로축 눈금 간격(㎥) — 5,000㎥ = 종이 100 mm. @@ -75,7 +98,7 @@ _BALLOON_STAGGER = 3 # 겹침 회피용 층 수 # 장비 키 → 도면 표기(실무 수량산출 용어: 무대·도자·덤프). EQUIPMENT_LABEL = { - "free_haul": "무대", + "free_haul": "종무대", "dozer": "도자", "dump_truck": "덤프", } @@ -86,9 +109,25 @@ _TABLE_ROWS: tuple[tuple[str, str, float], ...] = ( ("station", "측점", 7.0), ) _TABLE_LABEL_WIDTH = 16.0 # 좌측 행 이름 칸 폭(mm) +_TABLE_LABEL_FONT_SIZE = 3.2 # 행 이름은 본문보다 크게(납품 도면 표기) _TABLE_TOP_GAP = 6.0 # 그래프 최저점과 테이블 사이 간격(mm) +def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: + """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). + + 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). + """ + safe = interval_m if interval_m > 0 else 1.0 + number = int((chainage_m + 1e-6) // safe) + remainder = chainage_m - number * safe + if remainder >= safe - 0.05: + number += 1 + remainder = 0.0 + gap = " " if decimals == 1 else "" + return f"{number}+{gap}{remainder:.{decimals}f}" + + def _number(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback @@ -105,8 +144,8 @@ def _curve_points(mass_haul: dict[str, Any]) -> list[tuple[float, float]]: ] -def _paper(x_m: float, volume_m3: float) -> tuple[float, float]: - return (x_m * MM_H, volume_m3 * MM_V) +def _paper(x_m: float, volume_m3: float, mm_h: float) -> tuple[float, float]: + return (x_m * mm_h, volume_m3 * MM_V) def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) -> float: @@ -120,15 +159,15 @@ def _curve_top_at(curve: list[tuple[float, float]], from_m: float, to_m: float) def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> list[dict[str, Any]]: """좌측 세로축(빨강)·눈금·라벨 + 누가토량 0 기준선.""" entities: list[dict[str, Any]] = [] - top = _paper(0.0, max_v)[1] - bottom = _paper(0.0, min_v)[1] + top = max_v * MM_V + bottom = min_v * MM_V entities.append( _line_entity(f"{drawing_id}:axis:v", (x0, bottom), (x0, top), AXIS_LAYER_ID, AXIS_COLOR) ) start = int(min_v // AXIS_TICK_M3) * AXIS_TICK_M3 value = start while value <= max_v + 1e-6: - y = _paper(0.0, value)[1] + y = value * MM_V entities.append( _line_entity( f"{drawing_id}:axis:tick:{value:.0f}", @@ -202,17 +241,19 @@ def _balloon_entities( ) if shape_entity: entities.append(shape_entity) - # 지시선: balloon 아래(또는 위) 가장자리 → 띠 현 중앙. - edge_y = cy - half_h if anchor[1] < cy else cy + half_h - entities.append( - _line_entity( - f"{drawing_id}:balloon:leader:{seed}", - (cx, edge_y), - anchor, - BAND_LAYER_ID, - BAND_COLOR, - ) + # 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 — + # 납품 도면 표기). 종전에는 아래 가장자리 중앙에서 대각선 하나로 갔다. + corner_y = cy - half_h if anchor[1] < cy else cy + half_h + corner_x = cx - half_w if anchor[0] < cx else cx + half_w + leader = polyline_entity( + drawing_id, + [(corner_x, corner_y), (anchor[0], corner_y), anchor], + BAND_LAYER_ID, + BAND_COLOR, + suffix=f":balloon:leader:{seed}", ) + if leader: + entities.append(leader) first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 for index, line in enumerate(lines): entities.append( @@ -230,7 +271,7 @@ def _balloon_entities( def _band_entities( - drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]] + drawing_id: str, plan: dict[str, Any], curve: list[tuple[float, float]], mm_h: float ) -> list[dict[str, Any]]: """블록 평형선·띠 경계현·띠 balloon.""" entities: list[dict[str, Any]] = [] @@ -247,8 +288,8 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:block:{block.get('index')}", - _paper(from_m, base_m3), - _paper(to_m, base_m3), + _paper(from_m, base_m3, mm_h), + _paper(to_m, base_m3, mm_h), BAND_LAYER_ID, BALANCE_COLOR, ) @@ -266,20 +307,20 @@ def _band_entities( entities.append( _line_entity( f"{drawing_id}:band:boundary:{index}", - _paper(boundary_from, level_base), - _paper(boundary_to, level_base), + _paper(boundary_from, level_base, mm_h), + _paper(boundary_to, level_base, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) mid_level = (level_base + level_apex) / 2.0 entities.append( _line_entity( f"{drawing_id}:band:haul:{index}", - _paper(haul_from, mid_level), - _paper(haul_to, mid_level), + _paper(haul_from, mid_level, mm_h), + _paper(haul_to, mid_level, mm_h), BAND_LAYER_ID, - BAND_COLOR, + BAND_CHORD_COLOR, ) ) equipment = str(band.get("equipment") or "") @@ -288,18 +329,16 @@ def _band_entities( # 않아 적지 않는다 (2026-08-30 사용자 확정 — 확인되면 그때 붙인다). lines = [ label, - f"Q={_format(_number(band.get('volume_m3')))}M3", - f"L={_format(_number(band.get('haul_distance_m')))}M", - f"EA={_format(_number(band.get('ea_m3')))}M3", - f"RR={_format(_number(band.get('rr_m3')))}M3", - f"BR={_format(_number(band.get('br_m3')))}M3", + f"Q= {_format(_number(band.get('volume_m3')))}M3", + f"L= {_format(_number(band.get('haul_distance_m')))}M", + f"EA= {_format(_number(band.get('ea_m3')))}M3", + f"RR= {_format(_number(band.get('rr_m3')))}M3", + f"BR= {_format(_number(band.get('br_m3')))}M3", ] - anchor = _paper((haul_from + haul_to) / 2.0, mid_level) + anchor = _paper((haul_from + haul_to) / 2.0, mid_level, mm_h) top_m3 = _curve_top_at(curve, boundary_from, boundary_to) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, top_m3)[1] + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = top_m3 * MM_V + _BALLOON_GAP + height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, @@ -319,6 +358,7 @@ def _residual_entities( plan: dict[str, Any], curve: list[tuple[float, float]], interval_m: float, + mm_h: float, ) -> list[dict[str, Any]]: """사토·토취 balloon — 운반거리 대신 발생 측점(M.N)을 적는다.""" entities: list[dict[str, Any]] = [] @@ -334,20 +374,18 @@ def _residual_entities( from_m = _number(residual.get("from_m")) to_m = _number(residual.get("to_m"), from_m) level = _number(residual.get("level_from_m3")) - station = station_no_label(from_m, interval_m).removeprefix("No.") + station = station_plus_label(from_m, interval_m, decimals=2) lines = [ f"{kind} {index}", - f"Q={_format(_number(residual.get('volume_m3')))}M3", - f"M.N={station}", - f"EA={_format(_number(residual.get('ea_m3')))}M3", - f"RR={_format(_number(residual.get('rr_m3')))}M3", - f"BR={_format(_number(residual.get('br_m3')))}M3", + f"Q= {_format(_number(residual.get('volume_m3')))}M3", + f"M.N= {station}", + f"EA= {_format(_number(residual.get('ea_m3')))}M3", + f"RR= {_format(_number(residual.get('rr_m3')))}M3", + f"BR= {_format(_number(residual.get('br_m3')))}M3", ] - anchor = _paper((from_m + to_m) / 2.0, level) + anchor = _paper((from_m + to_m) / 2.0, level, mm_h) height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y - center_y = ( - _paper(0.0, bottom_m3)[1] - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) - ) + center_y = bottom_m3 * MM_V - _BALLOON_GAP - height * (0.5 + slot % _BALLOON_STAGGER) entities.extend( _balloon_entities( drawing_id, f"residual:{index}", lines, anchor, (anchor[0], center_y), "rect" @@ -362,6 +400,7 @@ def _table_entities( stations: list[dict[str, Any]], interval_m: float, top_y: float, + mm_h: float, ) -> list[dict[str, Any]]: """하단 2행 테이블(누가토량 / 측점). 값은 세로쓰기, 측점은 No. 표기.""" entities: list[dict[str, Any]] = [] @@ -369,9 +408,10 @@ def _table_entities( return entities cumulative = {round(x, 3): v for x, v in curve} xs = [x for x, _v in curve] - left = min(xs) * MM_H - _TABLE_LABEL_WIDTH - right = max(xs) * MM_H + left = min(xs) * mm_h - _TABLE_LABEL_WIDTH + right = max(xs) * mm_h + safe_interval = interval_m if interval_m > 0 else 1.0 y = top_y boundaries = [y] for _key, _label, height in _TABLE_ROWS: @@ -405,21 +445,21 @@ def _table_entities( entities.append( _text_entity( f"{drawing_id}:table:name:{key}", - label, + " ".join(label), left + _TABLE_LABEL_WIDTH / 2.0, center_y, TABLE_LAYER_ID, - _FONT_SIZE, + _TABLE_LABEL_FONT_SIZE, TABLE_LABEL_COLOR, ) ) for station in stations: chainage = _number(station.get("chainage_m")) - x = chainage * MM_H + x = chainage * mm_h if x < left + _TABLE_LABEL_WIDTH or x > right: continue if key == "station": - text = station_no_label(chainage, interval_m) + text = station_plus_label(chainage, interval_m) y_text = center_y direction = _VERTICAL else: @@ -429,6 +469,19 @@ def _table_entities( text = _format(value) y_text = row_bottom + 0.8 direction = _VERTICAL + # 표 눈금 — 정규 측점(간격의 배수)은 빨강, 그 사이 추가 측점은 회색. + if row_index == 0: + remainder = abs(chainage - round(chainage / safe_interval) * safe_interval) + regular = remainder < 0.05 + entities.append( + _line_entity( + f"{drawing_id}:table:tick:{chainage:.2f}", + (x, row_top), + (x, row_top - _TABLE_TICK_LEN), + TABLE_LAYER_ID, + TABLE_TICK_COLOR if regular else TABLE_TICK_EXTRA_COLOR, + ) + ) entities.append( _text_entity( f"{drawing_id}:table:{key}:{chainage:.2f}", @@ -457,38 +510,45 @@ def build_mass_haul_drawing( interval_m = infer_station_interval(all_stations) volumes = [v for _x, v in curve] min_v, max_v = min(volumes), max(volumes) + # 가로 축척은 노선 연장으로 정한다 — 한 장에 들어가는 가장 큰 그림 + # (2026-09-03 사용자 결정: 길이별 자동 축척). + length_m = max(x for x, _v in curve) - min(x for x, _v in curve) + scale_h = auto_scale_h(length_m) + mm_h = 1000.0 / scale_h entities: list[dict[str, Any]] = [] - x0 = min(x for x, _v in curve) * MM_H + x0 = min(x for x, _v in curve) * mm_h entities.extend(_axis_entities(drawing_id, x0, min(min_v, 0.0), max(max_v, 0.0))) entities.append( _line_entity( f"{drawing_id}:axis:zero", (x0, 0.0), - (max(x for x, _v in curve) * MM_H, 0.0), + (max(x for x, _v in curve) * mm_h, 0.0), AXIS_LAYER_ID, AXIS_COLOR, ) ) curve_entity = polyline_entity( - drawing_id, [_paper(x, v) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR + drawing_id, [_paper(x, v, mm_h) for x, v in curve], CURVE_LAYER_ID, CURVE_COLOR ) if curve_entity: entities.append(curve_entity) plan = mass_haul.get("haul_plan") if isinstance(plan, dict): - entities.extend(_band_entities(drawing_id, plan, curve)) - entities.extend(_residual_entities(drawing_id, plan, curve, interval_m)) + entities.extend(_band_entities(drawing_id, plan, curve, mm_h)) + entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h)) # 테이블은 그래프·balloon 어느 것보다도 아래에 둔다 — 사토·토취 balloon이 곡선 밑에 # 깔리므로 그래프 최저점만 보고 자리를 잡으면 표와 겹친다(2026-08-30 화면 실측). - graph_bottom = min(_paper(0.0, min_v)[1], 0.0) + graph_bottom = min(min_v * MM_V, 0.0) drawn = entities_bbox(entities) if drawn: graph_bottom = min(graph_bottom, drawn[1]) entities.extend( - _table_entities(drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP) + _table_entities( + drawing_id, curve, all_stations, interval_m, graph_bottom - _TABLE_TOP_GAP, mm_h + ) ) bbox = entities_bbox(entities) @@ -505,10 +565,7 @@ def build_mass_haul_drawing( TABLE_LABEL_COLOR, ) ) - scale_text = ( - f"SCALE H=1:{DRAWING_SCALE_MASSHAUL_H:,} " - f"V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" - ) + scale_text = f"SCALE H=1:{scale_h:,} V=1:{int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000):,}" entities.append( _text_entity( f"{drawing_id}:scale", @@ -526,7 +583,7 @@ def build_mass_haul_drawing( drawing_id, entities_bbox(entities) or bbox, fit=False, - fields={"도면명": "토적도", **scale_fields(("H", DRAWING_SCALE_MASSHAUL_H))}, + fields={"도면명": "토적도", **scale_fields(("H", scale_h))}, ) ) diff --git a/config/config_system.py b/config/config_system.py index 794fa3a8..407c5915 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -831,7 +831,24 @@ DRAWING_SHEET = "A1" # 용지 규격 (840x594 mm 도각 템플릿) # 축척분모를 그대로 못 쓴다 — 종이 1 mm가 받는 토량(50 ㎥)으로 적는다. # 유역도 : 평면 1/6,000 (가로·세로 같은 배율) # A1 유효 작도영역 693x468 mm를 넘으면 늘리지 않고 경고만 남긴다(척도 보존). -DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (실거리 1 m = 0.5 mm) +DRAWING_SCALE_MASSHAUL_H = 2000 # 유토곡선 가로 축척 분모 (자동 선정 실패 시 폴백) +# 토적도 가로 축척 후보 — 노선 연장에 맞춰 **한 장에 들어가는 가장 큰 그림**을 고른다 +# (2026-09-03 사용자 결정: 길이별 자동 축척). 도면 관행 축척만 둔다. +DRAWING_SCALE_MASSHAUL_H_CANDIDATES = ( + 500, + 600, + 1000, + 1200, + 1500, + 2000, + 2500, + 3000, + 4000, + 5000, + 6000, +) +# A1 작도영역 가로(770 mm)에서 여백 2%와 좌측 행 이름칸·축 여유를 뺀 실제 가용 폭. +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) -- 2.54.0 From 451ed9727931fc69ba6c52d8442570d5ff664d65 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:46:54 +0900 Subject: [PATCH 028/441] =?UTF-8?q?feat(B07):=20=EB=8F=8C=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=ED=95=B4=EC=B9=AD=20CAD=20=EB=B0=98=EC=98=81=20+?= =?UTF-8?q?=20=ED=91=9C=EC=A0=9C=EB=9E=80=20SVG=20=EC=84=9C=EB=AA=85=20?= =?UTF-8?q?=EB=B2=A1=ED=84=B0=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 해칭 — 수확기가 `g[clip-path]` 를 통째로 건너뛰어 기슭막이 형태 해칭(돌쌓기·콘크리트· 돌망태·통나무·바자)이 CAD 도면에 하나도 실리지 않았다. 클립을 쓰는 대신 **경계로 잘라서** 싣는다(2026-09-03 사용자 결정). 선분–폴리곤 교차로 안쪽 조각만 남기고, 클립 안 원(통나무 마구리)은 36각 폴리선으로 펴서 같은 방식으로 자른다. 문자는 중심점이 안일 때만 싣는다. 서명 — SVG 로 올라온 자산만 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정). DXF 로 나가도 선으로 남고 확대해도 깨지지 않는다. `B07_DesignDetail_Engine_Cad_Svg` 신설(path M·L·H·V· C·Q·S·T·Z 지원, 곡선은 16분할, viewBox 비율 유지·y 뒤집기). PNG·JPG 는 그릴 선이 없으므로 종전대로 ImageEntity, 읽기 실패도 원래 그림을 그대로 둔다. 검증 — `tmp/tests/test_b07_signature_vector.py` 5건 신설(파싱·비율·엔티티 교체·래스터 유지· 손상 SVG), 전체 383 passed·17 skipped. typecheck·prettier·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Engine_Cad_Svg.py | 202 ++++++++++++++++++ .../B07_DesignDetail_Engine_Template.py | 75 ++++++- .../B07_DesignDetail_UI_Cad_Structures.ts | 124 ++++++++++- 3 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py new file mode 100644 index 00000000..b99c8c33 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Svg.py @@ -0,0 +1,202 @@ +"""SVG 서명·로고를 CAD 폴리선으로 옮긴다 (2026-09-03 사용자 결정: 벡터로 변환). + +표제란 서명은 종전에 `ImageEntity`(data URL)로 들어갔다. 화면·PNG 는 문제없지만 DXF 로 +나가면 래스터 그림이 되고 확대하면 깨진다. **SVG 로 올라온 자산만** 벡터로 바꾼다 — +PNG·JPG 는 그릴 선이 없으므로 종전대로 그림으로 둔다. + +지원 도형: `path`(M·L·H·V·C·Q·S·T·A 제외·Z, 대소문자 = 절대·상대) · `polyline` · +`polygon` · `line`. 곡선은 고정 분할로 펴서 폴리선 하나로 만든다 — CAD 쪽에 곡선 +엔티티가 없고, 서명은 짧은 획이라 16분할이면 눈으로 구분되지 않는다. +""" + +from __future__ import annotations + +import logging +import re +from xml.etree import ElementTree + +logger = logging.getLogger(__name__) + +# 베지에 한 구간을 몇 조각으로 펼지 — 서명 획 길이(수 mm)에서 충분한 값. +_CURVE_STEPS = 16 +_NUMBER = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") +_COMMAND = re.compile(r"([MmLlHhVvCcQqSsTtZz])") +_SVG_NS = "{http://www.w3.org/2000/svg}" + +Point = tuple[float, float] + + +def _numbers(text: str) -> list[float]: + return [float(value) for value in _NUMBER.findall(text or "")] + + +def _bezier(points: list[Point], steps: int = _CURVE_STEPS) -> list[Point]: + """드 카스텔조 분할 — 3차·2차 모두 같은 식으로 편다(제어점 개수만 다르다).""" + flattened: list[Point] = [] + for index in range(1, steps + 1): + t = index / steps + current = points + while len(current) > 1: + current = [ + (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + for a, b in zip(current, current[1:]) + ] + flattened.append(current[0]) + return flattened + + +def _path_polylines(data: str) -> list[list[Point]]: + """`d` 속성 → 폴리선 목록. 미지원 명령(A 등)을 만나면 그 자리에서 획을 끊는다.""" + runs: list[list[Point]] = [] + current: list[Point] = [] + cursor: Point = (0.0, 0.0) + start: Point = (0.0, 0.0) + previous_control: Point | None = None + tokens = [token for token in _COMMAND.split(data or "") if token.strip()] + index = 0 + while index < len(tokens): + command = tokens[index] + args = _numbers(tokens[index + 1]) if index + 1 < len(tokens) else [] + index += 2 if index + 1 < len(tokens) else 1 + relative = command.islower() + upper = command.upper() + + def absolute(x: float, y: float) -> Point: + return (cursor[0] + x, cursor[1] + y) if relative else (x, y) + + if upper == "M": + for pair in range(0, len(args) - 1, 2): + point = absolute(args[pair], args[pair + 1]) + if pair == 0: + if len(current) >= 2: + runs.append(current) + current = [point] + start = point + else: + current.append(point) + cursor = point + previous_control = None + elif upper in {"L", "T"}: + for pair in range(0, len(args) - 1, 2): + point = absolute(args[pair], args[pair + 1]) + current.append(point) + cursor = point + previous_control = None + elif upper == "H": + for value in args: + point = (cursor[0] + value, cursor[1]) if relative else (value, cursor[1]) + current.append(point) + cursor = point + previous_control = None + elif upper == "V": + for value in args: + point = (cursor[0], cursor[1] + value) if relative else (cursor[0], value) + current.append(point) + cursor = point + previous_control = None + elif upper in {"C", "S", "Q"}: + stride = {"C": 6, "S": 4, "Q": 4}[upper] + for offset in range(0, len(args) - stride + 1, stride): + chunk = args[offset : offset + stride] + if upper == "C": + control1 = absolute(chunk[0], chunk[1]) + control2 = absolute(chunk[2], chunk[3]) + end = absolute(chunk[4], chunk[5]) + elif upper == "S": + mirrored = previous_control or cursor + control1 = (2 * cursor[0] - mirrored[0], 2 * cursor[1] - mirrored[1]) + control2 = absolute(chunk[0], chunk[1]) + end = absolute(chunk[2], chunk[3]) + else: + control1 = absolute(chunk[0], chunk[1]) + control2 = control1 + end = absolute(chunk[2], chunk[3]) + current.extend(_bezier([cursor, control1, control2, end])) + previous_control = control2 + cursor = end + elif upper == "Z": + if current: + current.append(start) + runs.append(current) + current = [] + cursor = start + previous_control = None + else: + # 지원하지 않는 명령(원호 A 등) — 여기서 획을 끊고 다음 M 을 기다린다. + if len(current) >= 2: + runs.append(current) + current = [] + previous_control = None + if len(current) >= 2: + runs.append(current) + return runs + + +def svg_polylines(svg_text: str) -> tuple[tuple[float, float, float, float], list[list[Point]]]: + """SVG 원문 → (viewBox, 폴리선 목록). 읽지 못하면 빈 목록. + + viewBox 가 없으면 도형 전체 bbox 를 대신 쓴다 — 자리 맞추기에만 쓰는 값이다. + """ + try: + root = ElementTree.fromstring(svg_text) + except ElementTree.ParseError as exc: + logger.warning("서명 SVG 를 읽지 못했습니다 — %s", exc) + return ((0.0, 0.0, 1.0, 1.0), []) + + runs: list[list[Point]] = [] + for element in root.iter(): + tag = element.tag.replace(_SVG_NS, "") + if tag == "path": + runs.extend(_path_polylines(element.get("d", ""))) + elif tag in {"polyline", "polygon"}: + values = _numbers(element.get("points", "")) + points = [(values[i], values[i + 1]) for i in range(0, len(values) - 1, 2)] + if tag == "polygon" and len(points) > 2: + points.append(points[0]) + if len(points) >= 2: + runs.append(points) + elif tag == "line": + runs.append( + [ + (float(element.get("x1", 0)), float(element.get("y1", 0))), + (float(element.get("x2", 0)), float(element.get("y2", 0))), + ] + ) + + box = _numbers(root.get("viewBox", "")) + if len(box) == 4 and box[2] > 0 and box[3] > 0: + return ((box[0], box[1], box[2], box[3]), runs) + xs = [x for run in runs for x, _y in run] + ys = [y for run in runs for _x, y in run] + if not xs or not ys: + return ((0.0, 0.0, 1.0, 1.0), runs) + width = max(max(xs) - min(xs), 1e-6) + height = max(max(ys) - min(ys), 1e-6) + return ((min(xs), min(ys), width, height), runs) + + +def fit_polylines( + runs: list[list[Point]], + view_box: tuple[float, float, float, float], + rect: tuple[float, float, float, float], +) -> list[list[Point]]: + """폴리선을 도면 사각형(x0, y0, x1, y1) 안에 **비율 유지**로 앉힌다. + + SVG 는 y 가 아래로 자라고 도면은 위로 자라므로 세로를 뒤집는다. + """ + vx, vy, vw, vh = view_box + x0, y0, x1, y1 = rect + box_w = abs(x1 - x0) + box_h = abs(y1 - y0) + if vw <= 0 or vh <= 0 or box_w <= 0 or box_h <= 0: + return [] + scale = min(box_w / vw, box_h / vh) + pad_x = (box_w - vw * scale) / 2.0 + pad_y = (box_h - vh * scale) / 2.0 + left = min(x0, x1) + bottom = min(y0, y1) + top = bottom + box_h + return [ + [(left + pad_x + (x - vx) * scale, top - pad_y - (y - vy) * scale) for x, y in run] + for run in runs + ] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index eb8dec65..ecec48f0 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -9,14 +9,18 @@ A1 템플릿 기하(변환 시점 고정값): 전체 840x594, 하단 y17~47 표 내부 작도 영역 (42, 47) ~ (812, 567). """ +import binascii import json import logging import re +from base64 import b64decode from contextvars import ContextVar from functools import lru_cache from pathlib import Path from typing import Any -from uuid import uuid5 +from uuid import NAMESPACE_URL, uuid5 + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, @@ -28,6 +32,10 @@ _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template logger = logging.getLogger(__name__) +# 벡터로 바꾼 서명 폴리선 id 를 결정적으로 만드는 이름공간 — 같은 도면을 두 번 뽑아도 +# 엔티티 id 가 같아야 CAD 가 같은 것으로 본다. +_SVG_NS_UUID = uuid5(NAMESPACE_URL, "aislo/b07/signature-vector") + A1_TEMPLATE = "00_template_A1" COMPASS_TEMPLATE = "00_template_compass" # 유역도 등 평면 도면의 방위표 # A1 내부 작도 영역(템플릿 좌표) — 콘텐츠가 이 영역 중앙에 오도록 배치한다. @@ -319,11 +327,74 @@ def _fill_placeholders( if entity.get("type") == "Image" and isinstance(image, str) and "{{" in image: shape["imageData"] = substitute(image) # 그림을 못 구한 자리는 엔티티째 뺀다 — 빈 문자열을 남기면 CAD가 깨진 그림을 그린다. - return [ + kept = [ entity for entity in entities if entity.get("type") != "Image" or (entity.get("shapeData") or {}).get("imageData") ] + return [replaced for entity in kept for replaced in _vectorize_svg_image(entity)] + + +def _vectorize_svg_image(entity: dict[str, Any]) -> list[dict[str, Any]]: + """SVG 로 올라온 서명·로고를 CAD 폴리선으로 바꾼다(2026-09-03 사용자 결정). + + 래스터(PNG·JPG)는 그릴 선이 없으므로 종전대로 `ImageEntity` 로 둔다. SVG 만 벡터로 + 바꿔 DXF 로 나가도 선으로 남고 확대해도 깨지지 않게 한다. 읽지 못하면 원래 그림을 + 그대로 둔다 — 서명이 통째로 사라지는 것보다 낫다. + """ + if entity.get("type") != "Image": + return [entity] + shape = entity.get("shapeData") or {} + data_url = shape.get("imageData") + if not isinstance(data_url, str) or not data_url.startswith("data:image/svg+xml;base64,"): + return [entity] + points = shape.get("points") or [] + if len(points) < 4: + return [entity] + try: + svg_text = b64decode(data_url.split(",", 1)[1]).decode("utf-8", errors="ignore") + except (ValueError, binascii.Error) as exc: + logger.warning("서명 SVG data URL 을 풀지 못했습니다 — %s", exc) + return [entity] + + view_box, runs = svg_polylines(svg_text) + if not runs: + return [entity] + xs = [float(point["x"]) for point in points] + ys = [float(point["y"]) for point in points] + fitted = fit_polylines(runs, view_box, (min(xs), min(ys), max(xs), max(ys))) + layer_id = entity.get("layerId") + color = entity.get("lineColor", "#000000") + vector: list[dict[str, Any]] = [] + for index, run in enumerate(fitted): + children = [ + { + "id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}:{step}")), + "type": "Line", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": { + "startPoint": {"x": run[step][0], "y": run[step][1]}, + "endPoint": {"x": run[step + 1][0], "y": run[step + 1][1]}, + }, + } + for step in range(len(run) - 1) + ] + if not children: + continue + vector.append( + { + "id": str(uuid5(_SVG_NS_UUID, f"{entity.get('id')}:{index}")), + "type": "PolyLine", + "lineColor": color, + "lineWidth": 1, + "layerId": layer_id, + "shapeData": None, + "children": children, + } + ) + return vector or [entity] def scale_fields(*denominators: tuple[str, int]) -> dict[str, str]: diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index b4458007..89a249f0 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -75,8 +75,10 @@ const LABEL_FONT_MM = 2.0; /** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ const CHAINAGE_TOLERANCE_M = 0.02; -/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */ -const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]"; +/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */ +const SKIP_SELECTOR = "defs, clipPath, pattern"; +/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */ +const CIRCLE_SEGMENTS = 36; type Entity = Record; type XY = [number, number]; @@ -241,6 +243,86 @@ function parsePoints(element: SVGElement): XY[] { return points; } +/** + * 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null. + * + * 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다. + * 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도 + * 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정). + */ +function clipPolygonOf(element: SVGElement): XY[] | null { + const group = element.closest("g[clip-path]"); + const reference = group?.getAttribute("clip-path") ?? ""; + const id = reference.match(/url\(#([^)]+)\)/)?.[1]; + if (!id) return null; + const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon"); + if (!shape) return null; + const points = parsePoints(shape as unknown as SVGElement); + return points.length >= 3 ? points : null; +} + +/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */ +function pointInPolygon(point: XY, polygon: XY[]): boolean { + let inside = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + const straddles = yi > point[1] !== yj > point[1]; + if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */ +function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] { + const [x0, y0] = start; + const [x1, y1] = end; + const dx = x1 - x0; + const dy = y1 - y0; + const cuts = [0, 1]; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) { + const [ex, ey] = polygon[j]; + const [fx, fy] = polygon[i]; + const denominator = dx * (fy - ey) - dy * (fx - ex); + if (Math.abs(denominator) < 1e-12) continue; + const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator; + const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator; + if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t); + } + cuts.sort((a, b) => a - b); + const runs: XY[][] = []; + for (let index = 0; index + 1 < cuts.length; index += 1) { + const from = cuts[index]; + const to = cuts[index + 1]; + if (to - from < 1e-9) continue; + const mid = (from + to) / 2; + if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue; + runs.push([ + [x0 + dx * from, y0 + dy * from], + [x0 + dx * to, y0 + dy * to], + ]); + } + return runs; +} + +/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */ +function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] { + const runs: XY[][] = []; + for (let index = 0; index + 1 < points.length; index += 1) { + runs.push(...clipSegment(points[index], points[index + 1], polygon)); + } + return runs; +} + +function circlePoints(center: XY, radius: number): XY[] { + const points: XY[] = []; + for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) { + const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + /** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const [fx0, , fx1] = frame; @@ -251,26 +333,46 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { for (const element of Array.from(nodes)) { if (element.closest(SKIP_SELECTOR)) continue; const tag = element.tagName.toLowerCase(); + // 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다. + const clip = clipPolygonOf(element); if (tag === "polygon" || tag === "polyline") { const points = parsePoints(element); if (tag === "polygon" && points.length > 2) points.push(points[0]); - const poly = polyEntity(style, simplify(clipX(points, fx0, fx1))); - if (poly) entities.push(poly); + const runs = clip ? clipPointsToPolygon(points, clip) : [points]; + for (const run of runs) { + const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); + if (poly) entities.push(poly); + } } else if (tag === "line") { const start = flip(attr(element, "x1"), attr(element, "y1")); const end = flip(attr(element, "x2"), attr(element, "y2")); - const cut = clipX([start, end], fx0, fx1); - if (cut.length === 2) segments.push(cut); + for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) { + const cut = clipX(run, fx0, fx1); + if (cut.length === 2) segments.push(cut); + } } else if (tag === "circle") { - const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy")); - if (!inside(cx)) continue; - entities.push( - baseEntity(style, "Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }), - ); + const center = flip(attr(element, "cx"), attr(element, "cy")); + if (!inside(center[0])) continue; + const radius = attr(element, "r"); + if (!clip) { + entities.push( + baseEntity(style, "Circle", { + center: { x: center[0], y: center[1] }, + radius, + }), + ); + continue; + } + // 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다. + for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) { + const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); + if (poly) entities.push(poly); + } } else if (tag === "text") { const label = (element.textContent ?? "").trim(); const at = flip(attr(element, "x"), attr(element, "y")); if (!label || !inside(at[0])) continue; + if (clip && !pointInPolygon(at, clip)) continue; const anchor = element.getAttribute("text-anchor"); const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center"; entities.push(textEntity(style, label, at, align)); -- 2.54.0 From d6d211ef6125eb6d2a5d3023ac19e1768cacd3f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:50:53 +0900 Subject: [PATCH 029/441] =?UTF-8?q?refactor(B06):=20=EC=9C=A0=ED=86=A0?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=ED=8C=A8=EB=84=90=20=EC=A1=B0=EB=A6=BD?= =?UTF-8?q?=EC=9D=84=20=EC=A0=84=EC=9A=A9=20=EB=AA=A8=EB=93=88=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(700=EC=A4=84=20=EC=A0=9C=ED=95=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_UI_Section_View` 751줄 → 689줄. 곡선 계산 → 차트 → 범례 → 요약줄까지 한 덩어리를 `_UI_Section_View_MassHaul` 로 옮기고, 뷰 컨트롤러는 **붙일 자리**만 정한다(차트는 종단면도와 같은 부모의 형제, 범례는 스크롤 컨테이너 밖). 낡음 판정·계산은 옮긴 자리에서도 공용 모듈 그대로라 동작이 바뀌지 않는다. 검증 — 공용 브라우저 B06 실측: 요약줄 문자열 동일 (`절토(자연) 4,526.8㎥ … 최종 누가토량 −10,213.3㎥`), 범례 1·차트 1·카드 65·상태줄 빈 문자열. pytest 383 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Section_View.ts | 120 ++++----------- .../B06_Section_UI_Section_View_MassHaul.ts | 141 ++++++++++++++++++ 2 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Section_View_MassHaul.ts diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index e3a56afd..2bf11752 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -41,23 +41,10 @@ import { import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; -import { - applyLegendToggle, - computeMassHaulSeries, - MASS_HAUL_BALANCE_KEY, - type MassHaulSeries, -} from "@util/common_util_mass_haul"; -import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; -import { - configureBalloonOffsets, - resetBalloonOffsets, -} from "@util/common_util_mass_haul_balance_view"; -import { - createMassHaulChart, - createMassHaulLegend, - createMassHaulSummary, - MASS_HAUL_MIN_HEIGHT, -} from "@util/common_util_mass_haul_view"; +import { applyLegendToggle } from "@util/common_util_mass_haul"; +import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; +import { buildMassHaulPanel } from "./B06_Section_UI_Section_View_MassHaul"; import { BASE_PANEL_HEIGHT, chartHeights, @@ -82,9 +69,6 @@ import { emptyView, inferStationInterval, L, - longitudinalMaxChainage, - LONG_PAD, - staleDesignChainages, type YScaleOptions, } from "./B06_Section_UI_Section_Common"; @@ -495,7 +479,6 @@ export function createSectionView( // Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자 // 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식). let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; - let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const nodes: Element[] = [ unwrapChart( createLongitudinalProfile( @@ -517,58 +500,31 @@ export function createSectionView( ), ]; - // 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 **옛 계획고로 만든 값**이다. 진입 - // 직후 `reconcileStaleDesigns()`가 다시 계산해 갈아 끼우므로, 그 사이 옛 값으로 곡선을 - // 그리면 사용자가 옛 그림을 본다(2026-09-03 사용자 확정: 「새 값만 보여주기」). - const pendingRecalc = staleDesignChainages(detail).length > 0; - const series: MassHaulSeries[] = - currentConversion && !pendingRecalc - ? computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - currentConversion, - currentNaturalSpoilSlope, - ) - : []; - // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 - // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. - const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); - const haulPlan = - bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(bandedSeries.result, currentHaulLimits) - : null; - if (series.length) { - // 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면 - // 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다. - nodes.push( - createMassHaulChart( - series, - visibleSeries, - detail.longitudinal, - { - maxChainageM: longitudinalMaxChainage(detail.longitudinal), - padLeft: LONG_PAD.left, - padRight: LONG_PAD.right, - }, - selectedStationId, - cachedStationInterval, - chartWidth, - heights.mass, - minWidth, - (stationId) => selectStation(stationId, true), - haulPlan, - (axis) => { - massAxis = axis; - }, - ), - ); - } + // 유토곡선 몫은 전용 모듈이 만든다(2026-09-03 · 700줄 제한) — 곡선 계산부터 + // 범례·요약줄까지 한 덩어리라 붙이는 자리만 여기서 정한다. + const massHaul = buildMassHaulPanel({ + detail, + conversion: currentConversion, + haulLimits: currentHaulLimits, + naturalSpoilSlope: currentNaturalSpoilSlope, + visibleSeries, + selectedStationId, + stationInterval: cachedStationInterval, + chartWidth, + minWidth, + massHeight: heights.mass, + longHeight: heights.long, + selectStation: (stationId) => selectStation(stationId, true), + toggleSeries, + redraw: drawPanel, + }); + if (massHaul.chart) nodes.push(massHaul.chart); chartWrap.replaceChildren(...nodes); // 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼 // 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다. - if (massAxis) { - const overlay = buildStickyYAxis(massAxis, heights.mass); + if (massHaul.axis) { + const overlay = buildStickyYAxis(massHaul.axis, heights.mass); (overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`; chartWrap.prepend(overlay); } @@ -576,28 +532,10 @@ export function createSectionView( panel.querySelector(".b06-masshaul__legend")?.remove(); panel.querySelector(".b06-masshaul__summary")?.remove(); - // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 다만 **스크롤 컨테이너 - // 밖**에서 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 두 그래프의 - // 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). 세로 위치는 종단면도 높이로 잡는다. - if (series.length) { - const legend = createMassHaulLegend(series, visibleSeries, toggleSeries, () => { - resetBalloonOffsets(); - drawPanel(); - }); - legend.style.top = `${heights.long + 6}px`; - panelBody.append(legend); - } - // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. - // (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.) - const summarySeries = bandedSeries; - if (summarySeries) { - panelCount.textContent = ""; - panelBody.append(createMassHaulSummary(summarySeries, haulPlan)); - } else { - panelCount.textContent = pendingRecalc - ? L("B06_MassHaul_Recalculating") - : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); - } + // 범례는 스크롤 컨테이너 **밖**에 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 + // 끼어들어 두 그래프의 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). + panelBody.append(...massHaul.overlays); + panelCount.textContent = massHaul.statusText; chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts new file mode 100644 index 00000000..edf02dfa --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * B06_Section_UI_Section_View_MassHaul.ts + * B06 상단 패널의 **유토곡선 몫**만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). + * + * 뷰 컨트롤러(`_UI_Section_View`)가 종단면도와 카드 그리드를 맡고, 곡선 계산 → 차트 → + * 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈 + * (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다. + * + * 낡음 판정(`staleDesignChainages`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 + * 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정: + * 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면 + * 사용자가 옛 그림을 본다. + * ========================================================================== */ + +import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch"; +import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { + computeMassHaulSeries, + MASS_HAUL_BALANCE_KEY, + type MassHaulSeries, +} from "@util/common_util_mass_haul"; +import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; +import { + createMassHaulChart, + createMassHaulLegend, + createMassHaulSummary, +} from "@util/common_util_mass_haul_view"; +import { + L, + longitudinalMaxChainage, + LONG_PAD, + staleDesignChainages, +} from "./B06_Section_UI_Section_Common"; + +/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */ +export interface MassHaulAxisTicks { + padLeft: number; + ticks: Array<{ y: number; label: string }>; +} + +export interface MassHaulPanelInput { + detail: SectionDetailResponse; + conversion: EarthworkConversion | undefined; + haulLimits: HaulEquipmentLimit[] | undefined; + naturalSpoilSlope: number | undefined; + visibleSeries: Set; + selectedStationId: string | null; + stationInterval: number; + chartWidth: number; + minWidth: number; + /** 유토곡선 몫 높이(px)와 그 위 종단면도 높이(px) — 범례·Y축 자리를 잡는 값. */ + massHeight: number; + longHeight: number; + selectStation: (stationId: string) => void; + toggleSeries: (key: string) => void; + /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ + redraw: () => void; +} + +export interface MassHaulPanelResult { + /** 차트 SVG — 곡선이 없으면 null(그 자리는 비운다). */ + chart: Element | null; + axis: MassHaulAxisTicks | null; + /** 범례·요약줄 — 패널 본문에 붙일 순서대로. */ + overlays: HTMLElement[]; + /** 곡선을 못 그린 이유(있으면 상태줄에 그대로 적는다). 그릴 수 있으면 빈 문자열. */ + statusText: string; +} + +/** + * 유토곡선 차트·범례·요약줄을 만든다. DOM 에 붙이는 것은 호출한 쪽 몫이다 — + * 차트는 종단면도와 **같은 부모의 형제**여야 하고(감싸는 상자가 하나라도 끼면 스크롤 + * 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라 + * 붙일 자리가 서로 다르기 때문이다. + */ +export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { + const { detail, visibleSeries } = input; + const pendingRecalc = staleDesignChainages(detail).length > 0; + const series: MassHaulSeries[] = + input.conversion && !pendingRecalc + ? computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + input.conversion, + input.naturalSpoilSlope, + ) + : []; + // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 + // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. + const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); + const haulPlan = + bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) + ? computeHaulPlan(bandedSeries.result, input.haulLimits) + : null; + + let axis: MassHaulAxisTicks | null = null; + const chart = series.length + ? createMassHaulChart( + series, + visibleSeries, + detail.longitudinal, + { + maxChainageM: longitudinalMaxChainage(detail.longitudinal), + padLeft: LONG_PAD.left, + padRight: LONG_PAD.right, + }, + input.selectedStationId, + input.stationInterval, + input.chartWidth, + input.massHeight, + input.minWidth, + input.selectStation, + haulPlan, + (next) => { + axis = next; + }, + ) + : null; + + const overlays: HTMLElement[] = []; + if (series.length) { + // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 세로 자리는 + // 종단면도 높이로 잡는다. + const legend = createMassHaulLegend(series, visibleSeries, input.toggleSeries, () => { + resetBalloonOffsets(); + input.redraw(); + }); + legend.style.top = `${input.longHeight + 6}px`; + overlays.push(legend); + } + // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. + if (bandedSeries) { + overlays.push(createMassHaulSummary(bandedSeries, haulPlan)); + return { chart, axis, overlays, statusText: "" }; + } + const statusText = pendingRecalc + ? L("B06_MassHaul_Recalculating") + : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); + return { chart, axis, overlays, statusText }; +} -- 2.54.0 From 15c2d81b49bb9207886513478750d2cceabc3760 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 13:56:01 +0900 Subject: [PATCH 030/441] =?UTF-8?q?refactor(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=BB=8D=EB=8D=B0=EA=B8=B0=C2=B7=EB=B0=98?= =?UTF-8?q?=ED=8F=AD=20=EA=B3=84=EC=82=B0=20=EB=B6=84=EB=A6=AC=20(700?= =?UTF-8?q?=EC=A4=84=20=EC=A0=9C=ED=95=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_UI_Cross_View` 784줄 → 698줄. 도면과 무관한 제목행·하단 정보행을 `_UI_Cross_Card_Chrome` 으로 옮기고, 실효 표시 반폭 계산은 순수 계산이라 `_UI_Cross_View_Metrics` 로, 측점별 줌·팬 상태 맵은 임자인 `_UI_Cross_View_Zoom` 으로 옮겼다. 계산·판정 규칙은 그대로다. 검증 — 공용 브라우저 B06 실측: 카드 65·제목행 65·하단행 65·단면유형 pill 65·사면 미교차 경고 13, 첫 카드 제목행 `0+0.0 / 0.0m / 토사·리핑암·발파암 / 편측 성토 / BP` 로 종전과 동일. pytest 383 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Card_Chrome.ts | 102 +++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 106 ++---------------- .../B06_Section_UI_Cross_View_Metrics.ts | 27 +++++ B06_Section/B06_Section_UI_Cross_View_Zoom.ts | 3 + 4 files changed, 142 insertions(+), 96 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Card_Chrome.ts diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts new file mode 100644 index 00000000..4d57f093 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Card_Chrome.ts + * 횡단 카드의 **껍데기**(제목행·하단 정보행)만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). + * + * 카드 본체(`_UI_Cross_View`)는 SVG 도면과 조작을 맡고, 도면과 무관한 머리·꼬리는 여기서 + * 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; +import { + buildDesignControls, + buildRockBoundaryControl, + sectionModeLabel, +} from "./B06_Section_UI_Cross_Design"; +import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common"; + +/** + * 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지). + * + * 1행 구조(E-2/E-3): 측점 위치표기 → 지반유형 → 단면유형 pill → 구조물 → 측점정보. + */ +export function appendCardHeader( + card: HTMLElement, + section: CrossSection, + stationInterval: number, + onDesignChange?: DesignChangeHandler, +): void { + const header = document.createElement("header"); + const title = document.createElement("div"); + title.className = "b06-cross-card__title"; + const label = document.createElement("strong"); + label.textContent = stationLabel(section.chainage_m, stationInterval); + const chainage = document.createElement("span"); + chainage.textContent = `${section.chainage_m.toFixed(1)}m`; + title.append(label, chainage); + + // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). + const modePill = document.createElement("span"); + modePill.className = "b06-cross-card__mode"; + modePill.textContent = sectionModeLabel(section.design?.section_mode); + modePill.title = L("B06_Design_Mode_Legend"); + + const meta = document.createElement("div"); + meta.className = "b06-cross-card__meta"; + // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 + // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). + if (section.design?.slope_unclosed) { + const openSlope = document.createElement("span"); + openSlope.className = "b06-cross-card__warning"; + openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; + openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); + meta.append(openSlope); + } + if (section.structure) { + const structure = document.createElement("span"); + structure.className = "b06-cross-card__structure"; + structure.textContent = section.structure; + structure.title = `구조물: ${section.structure}`; + meta.append(structure); + } + // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). + if (section.kind === "bp" || section.kind === "ep") { + const kind = document.createElement("span"); + kind.textContent = L( + section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP", + ); + meta.append(kind); + } + + // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). + modePill.classList.add("b06-cross-card__mode--right"); + header.append(title); + if (onDesignChange) { + const controls = buildDesignControls(section, onDesignChange); + controls.groundSegment.classList.add("b06-cross-card__ground"); + header.append(controls.groundSegment, modePill, meta); + card.append(header, controls.bar); + return; + } + header.append(modePill, meta); + card.append(header); +} + +/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */ +export function appendCardFooter( + card: HTMLElement, + section: CrossSection, + rockBoundary?: RockBoundaryControl, +): void { + const footer = document.createElement("footer"); + const center = document.createElement("span"); + center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; + footer.append(center); + // 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만. + if (rockBoundary && section.design?.geometry_preset === "rock") { + const rockControl = buildRockBoundaryControl(section, rockBoundary); + rockControl.classList.add("b06-cross-card__rockb"); + footer.append(rockControl); + } + card.append(footer); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 941785d2..fe7595aa 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -23,11 +23,9 @@ import { appendCrossDesignOverlay, appendPavementOverlay, appendRockBoundaryOverlay, - buildDesignControls, - buildRockBoundaryControl, - sectionModeLabel, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; +import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert"; import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box"; @@ -35,7 +33,7 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; -import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { ExtraWallControl, @@ -47,8 +45,8 @@ import type { import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; -import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; -import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom"; +import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -60,12 +58,11 @@ import { type DesignChangeHandler, emptyView, L, - stationLabel, svgElement, svgText, validElevation, } from "./B06_Section_UI_Section_Common"; -import { crossPlotMetrics } from "./B06_Section_UI_Cross_View_Metrics"; +import { crossPlotMetrics, effectiveCardHalfWidth } from "./B06_Section_UI_Cross_View_Metrics"; export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics"; /** @@ -113,9 +110,6 @@ export type { StructureSpanControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; -/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */ -const cardZoomStates = new Map(); - export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -154,24 +148,10 @@ export function createCrossSectionCard( // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 // (2026-08-24 사용자). 길이·전/후 같은 구간 값은 소유 측점 한 곳에서 관리한다. const isLinkedCulvert = !section.culvert && !!culvertLink; - // 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점 - // (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편 - // 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점 = toeFitHalfWidth). - // 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 - // [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. - const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; - const sampledExtent = Math.max( - 0, - ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + const effectiveHalfWidth = effectiveCardHalfWidth( + section, + stationWidth?.widthFor(section) ?? crossHalfWidth, ); - const requiredHalfWidth = Math.min( - Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), - sampledExtent, - ); - const effectiveHalfWidth = - baseHalfWidth !== undefined && requiredHalfWidth > 0 - ? Math.max(baseHalfWidth, requiredHalfWidth) - : baseHalfWidth; const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; @@ -291,63 +271,7 @@ export function createCrossSectionCard( onAreaSelect?.(section.station_id, activeArea); }; - // 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보. - const header = document.createElement("header"); - const title = document.createElement("div"); - title.className = "b06-cross-card__title"; - const label = document.createElement("strong"); - label.textContent = stationLabel(section.chainage_m, stationInterval); - const chainage = document.createElement("span"); - chainage.textContent = `${section.chainage_m.toFixed(1)}m`; - title.append(label, chainage); - - // 단면유형 pill(자동 판정, 읽기 전용) — 구조물 pill과 동일 표기, 좌측 배치(E-3). - const modePill = document.createElement("span"); - modePill.className = "b06-cross-card__mode"; - modePill.textContent = sectionModeLabel(section.design?.section_mode); - modePill.title = L("B06_Design_Mode_Legend"); - - const meta = document.createElement("div"); - meta.className = "b06-cross-card__meta"; - // 사면이 계산 반폭 끝까지 원지반을 못 만난 측점 — 면적이 거기서 잘려 유토곡선까지 - // 그 값을 쌓는다. 계산은 손대지 않고 사실만 알린다(2026-09-03 사용자 확정). - if (section.design?.slope_unclosed) { - const openSlope = document.createElement("span"); - openSlope.className = "b06-cross-card__warning"; - openSlope.textContent = `⚠ ${L("B06_Cross_SlopeUnclosed")}`; - openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); - meta.append(openSlope); - } - if (section.structure) { - const structure = document.createElement("span"); - structure.className = "b06-cross-card__structure"; - structure.textContent = section.structure; - structure.title = `구조물: ${section.structure}`; - meta.append(structure); - } - // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). - if (section.kind === "bp" || section.kind === "ep") { - const kind = document.createElement("span"); - kind.textContent = L( - section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP", - ); - meta.append(kind); - } - - // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). - modePill.classList.add("b06-cross-card__mode--right"); - header.append(title); - if (onDesignChange) { - // 제목행: 측점 라벨 → (구분선) 지반유형 → 단면유형 pill(우측) → 구조물·kind (D-6/E-2/1·3번). - const controls = buildDesignControls(section, onDesignChange); - controls.groundSegment.classList.add("b06-cross-card__ground"); - header.append(controls.groundSegment, modePill, meta); - card.append(header); - card.append(controls.bar); - } else { - header.append(modePill, meta); - card.append(header); - } + appendCardHeader(card, section, stationInterval, onDesignChange); const metrics = crossPlotMetrics( section, @@ -769,16 +693,6 @@ export function createCrossSectionCard( card.append(chartWrap); } - const footer = document.createElement("footer"); - const center = document.createElement("span"); - center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; - footer.append(center); - // 암 경계선 제어는 하단 정보 행 가운데(2026-08-02) — 암 지반만. - if (rockBoundary && section.design?.geometry_preset === "rock") { - const rockControl = buildRockBoundaryControl(section, rockBoundary); - rockControl.classList.add("b06-cross-card__rockb"); - footer.append(rockControl); - } - card.append(footer); + appendCardFooter(card, section, rockBoundary); return card; } diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts index 04cdd36b..29721ee5 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -1,5 +1,7 @@ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common"; +import { culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; const AREA_OVERLAY_HEADROOM_PX = 58; @@ -73,3 +75,28 @@ export function crossCardNaturalHeight( ?.heightPx ?? CROSS_HEIGHT ); } + +/** + * 카드가 실제로 그릴 표시 반폭 — 개별값 > 전역값(2026-08-06). + * + * 절·성토선이 원지반과 만나는 지점(교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다 + * (2026-08-22, 판정 기준 개편 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점). + * 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 + * [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. + */ +export function effectiveCardHalfWidth( + section: CrossSection, + baseHalfWidth: number | undefined, +): number | undefined { + const sampledExtent = Math.max( + 0, + ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + ); + const requiredHalfWidth = Math.min( + Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), + sampledExtent, + ); + return baseHalfWidth !== undefined && requiredHalfWidth > 0 + ? Math.max(baseHalfWidth, requiredHalfWidth) + : baseHalfWidth; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts index 271cd768..9ccb9de7 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Zoom.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Zoom.ts @@ -53,6 +53,9 @@ export interface ContentBounds { height: number; } +/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */ +export const cardZoomStates = new Map(); + export function attachZoomPan( svg: SVGSVGElement, plotLayer: SVGGElement, -- 2.54.0 From 754e00de192f6458bddc1a729dbdfc76c7187a88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 14:06:11 +0900 Subject: [PATCH 031/441] =?UTF-8?q?fix(B07):=20=EC=9E=A5=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=20=EC=A0=95=EB=B3=B4=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=9D=84=20=EC=9E=A5=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장(여러 측점을 담은 횡단 도면)에는 측점 단위 지반·계획 값이 없는데도 제목이 「측점 3장 (220~264m)」으로 달려 어느 측점 값인지 오해됐다. 장이면 제목을 「장」으로 바꾸고 「이 장은 측점 여러 개를 담습니다. 지반·계획 정보는 측점 도면에서 봅니다」로 안내한다. 판정은 도면 id(`cross_s…` = 장) 로 한다 — `chainage_m` 은 장에도 첫 측점 값이 실려 와 그것으로는 갈리지 않는다(실측). 검증 — 공용 브라우저 B07: 3장(220~264m) 선택 시 `장 3장 (220~264m) · 잠정 · 이 장은 측점 여러 개를 담습니다…`, 종단도 선택 시 패널 없음(종전과 같음). typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 38 ++++++++++++++++---- ui_template/ui_template_locale_b2.ts | 7 ++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 575a7fd1..8fd49e2a 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -208,6 +208,11 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { return L("B06_Design_Mode_BothFill"); } +/** 장(여러 측점을 담은 횡단 도면)인가 — id 가 `cross_s…` 면 장이다. */ +function isCrossSheet(drawing: DesignDrawingItem): boolean { + return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); +} + /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; @@ -230,14 +235,25 @@ function infoRow(label: string, value: string): HTMLElement { return row; } -/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */ -function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement { +/** + * 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). + * + * **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지 + * 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리). + * 장이면 제목을 「장」으로 바꾸고 어디서 봐야 하는지 한 줄로 알린다. + */ +function buildDesignInfoPanel( + title: string, + design: CrossDesignInfo | null, + scope: "station" | "sheet" = "station", +): HTMLElement { const panel = document.createElement("div"); panel.className = "b07-info"; const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); - stationName.textContent = `${L("B07_Info_Station")} ${title}`; + 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" : ""}`; @@ -248,7 +264,7 @@ function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HT if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; - empty.textContent = L("B07_Info_NoDesign"); + empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); panel.append(empty); return panel; } @@ -341,8 +357,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { infoPanelHost.replaceChildren(); return; } - const title = drawing.label; - infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null)); + // 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은 + // 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다. + const scope = isCrossSheet(drawing) ? "sheet" : "station"; + infoPanelHost.replaceChildren( + buildDesignInfoPanel(drawing.label, response.design ?? null, scope), + ); }; /** @@ -544,7 +564,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다. if (currentDrawing.kind === "cross") { infoPanelHost.replaceChildren( - buildDesignInfoPanel(currentDrawing.label, result.design ?? null), + buildDesignInfoPanel( + currentDrawing.label, + result.design ?? null, + isCrossSheet(currentDrawing) ? "sheet" : "station", + ), ); } showToast("현재 도면을 확정하고 저장했습니다.", "success"); diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index c87995bd..893a1a3f 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -576,6 +576,13 @@ export const ui_locales_b2 = { B07_Info_Confirmed: ["확정", "Confirmed"], B07_Info_NoDesign: ["지반·계획 지정 데이터가 없습니다.", "No ground/plan designation data."], B07_Info_Station: ["측점", "Station"], + /* 장(여러 측점을 담은 횡단 도면)은 측점 단위 지반·계획 정보를 갖지 않는다 — + 제목을 「측점」으로 달면 어느 측점 값인지 오해된다(2026-09-03 정리). */ + B07_Info_Sheet: ["장", "Sheet"], + B07_Info_SheetHint: [ + "이 장은 측점 여러 개를 담습니다. 지반·계획 정보는 측점 도면에서 봅니다.", + "This sheet holds several stations. Ground and plan details live on each station drawing.", + ], /* --- B08_Quantity 수량 산출 --- */ B08_Quantity_Title: ["수량산출", "Quantity"], -- 2.54.0 From 5331167439bae6ea7d41b9bafe311766dff47b87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 15:47:16 +0900 Subject: [PATCH 032/441] =?UTF-8?q?refactor(B05):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=ED=8F=BC=20=EB=BC=88=EB=8C=80?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EC=9A=A9=20=EC=A1=B0=EB=A6=BD=EA=B8=B0?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(700=EC=A4=84=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=201=EC=B0=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_UI_Structures_Panel` 1,078줄 → 1,024줄. 화면 요소를 세우는 몫만 `_UI_Structures_Form` 으로 옮기고 패널은 값·검증·저장·목록을 맡는다. 배치 규칙(행 순서·구분선·버튼 묶음)은 사용자 지시로 굳은 것이라 그대로 옮겼다. 아직 제한을 넘는다 — 남은 큰 덩어리는 `commit()` 약 200줄과 옵션 필드 렌더·시설 폼 로드 약 170줄이다. 둘 다 폼 상태를 깊게 만지므로 [추가]·[삭제] 실동작 검증을 붙여 따로 옮긴다. 검증 — 공용 브라우저 B05 「구조물 배치」 실측: 제목·측점 행·메모 칸·목록 1개, 버튼 [추가]·[삭제]·[리셋], 구조물군 7항목 그대로. pytest 383 passed·17 skipped, typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Structures_Form.ts | 155 ++++++++++++++++++ .../B05_Profile_UI_Structures_Panel.ts | 110 ++++--------- 2 files changed, 183 insertions(+), 82 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Form.ts diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts new file mode 100644 index 00000000..d7410144 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Form.ts + * 「구조물 배치」 폼의 **뼈대**만 세우는 조립기 (2026-09-03 · 700줄 제한). + * + * 패널 본체(`_UI_Structures_Panel`)는 값 읽기·검증·저장·목록 갱신을 맡고, 여기서는 + * 화면 요소를 만들어 넘기기만 한다. 배치 규칙(행 순서·구분선·버튼 묶음)은 사용자 지시로 + * 굳은 것이라 그대로 옮겼다. + * + * 화면 구성(2026-08-17~29 사용자 지시): + * [구조물군][종류] ← 2열 격자 + * [측점 범위] [시작 측점] [기준 측점] [종료 측점] ← 타입에 따라 보이는 행이 갈린다 + * ──────── 구분선 + * [옵션 격자] / [계곡 통과 시설 서브폼] + * [추가·삭제·리셋] + * 목록(`list`)은 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. + * ========================================================================== */ + +import { + field, + select, + type StationFields, + stationFields, +} from "./B05_Profile_UI_Structures_Fields"; +import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; + +export interface StructuresFormElements { + root: HTMLElement; + body: HTMLElement; + groupSelect: HTMLSelectElement; + typeSelect: HTMLSelectElement; + startFields: StationFields; + anchorFields: StationFields; + endFields: StationFields; + memoField: HTMLInputElement; + /** 계산으로 채우는 측점 범위 표시(직접 입력이 아니다). */ + rangeValue: HTMLElement; + rangeWrap: HTMLElement; + positionRow: HTMLElement; + /** 측점 그룹과 옵션 사이 구분선 — 타입이 없으면 함께 숨긴다. */ + positionDivider: HTMLElement; + optionRow: HTMLElement; + /** 버튼 묶음 — 패널이 그 앞에 메모 칸을 끼워 넣는다. */ + actions: HTMLElement; + facilityOptions: ReturnType; + primary: HTMLButtonElement; + removeButton: HTMLButtonElement; + resetButton: HTMLButtonElement; + /** 하단 고정 영역에 패널이 따로 붙이는 구조물 목록. */ + list: HTMLElement; +} + +/** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */ +export function buildStructuresForm(options: { + /** 측점 잔여거리 정규화에 쓰는 현재 측점 간격(m). */ + getInterval: () => number; + /** 계곡 통과 시설 서브폼 값이 바뀔 때 — 패널이 즉시 반영을 건다. */ + onFacilityChange: () => void; +}): StructuresFormElements { + const root = document.createElement("section"); + // b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로 + // 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일). + root.className = + "b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section"; + const heading = document.createElement("h3"); + heading.className = "ui-collapsible__title"; + heading.textContent = "구조물 배치"; + const body = document.createElement("div"); + body.className = "b05-route__panel-body"; + root.append(heading, body); + + const groupSelect = select([]); + const typeSelect = select([]); + // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). + // 점형은 기준 측점만 보인다. 잔여거리는 [0, 간격) — 넘치면 측점번호로 올려 표시한다. + const startFields = stationFields("시작 측점", options.getInterval); + const anchorFields = stationFields("기준 측점", options.getInterval); + const endFields = stationFields("종료 측점", options.getInterval); + const memoField = document.createElement("input"); + memoField.type = "text"; + memoField.placeholder = "메모(선택)"; + + // 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은 + // 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다 + // (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점. + const rangeValue = document.createElement("span"); + rangeValue.className = "b05-structure__range-value"; + const rangeWrap = field("측점 범위", rangeValue); + + // 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2). + const positionRow = document.createElement("div"); + positionRow.className = "b05-structure__position-row"; + positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap); + + // 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1). + const typeRow = document.createElement("div"); + typeRow.className = "b05-structure__grid"; + typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); + + // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. + const optionRow = document.createElement("div"); + optionRow.className = "b05-structure__grid"; + + // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 자리에서 + // 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 사용자 지시 — + // 자동·수동 배관은 같은 구조물, 별도 UI 금지). + const facilityOptions = createFacilityOptionsForm({ onChange: options.onFacilityChange }); + + const primary = document.createElement("button"); + primary.type = "button"; + primary.className = "b05-route__irregular-btn is-primary"; + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "b05-route__irregular-btn is-danger"; + removeButton.textContent = "삭제"; + const resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.className = "b05-route__irregular-btn"; + resetButton.textContent = "리셋"; + const actions = document.createElement("div"); + actions.className = "b05-route__irregular-actions"; + actions.append(primary, removeButton, resetButton); + + // 목록은 이 섹션 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. + // 패널(B05_Profile_UI_Panel)이 하단 고정 영역에 배치한다(2026-08-18 사용자 지시). + const list = document.createElement("ul"); + list.className = "b05-route__irregular-list"; + + // 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2). + const positionDivider = document.createElement("hr"); + positionDivider.className = "b05-structure__divider"; + + body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); + + return { + root, + body, + groupSelect, + typeSelect, + startFields, + anchorFields, + endFields, + memoField, + rangeValue, + rangeWrap, + positionRow, + positionDivider, + optionRow, + actions, + facilityOptions, + primary, + removeButton, + resetButton, + list, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 49a37b3f..892cde77 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -23,11 +23,11 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { - createFacilityOptionsForm, type FacilityAttributes, type FacilityOptionsForm, } from "./B05_Profile_UI_Drainage_Facility"; -import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; +import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields"; +import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { formatStation } from "./B05_Profile_Util_Station"; @@ -115,58 +115,32 @@ interface StructuresCallbacks { } export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { - const root = document.createElement("section"); - // b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로 - // 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일). - root.className = - "b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section"; - const heading = document.createElement("h3"); - heading.className = "ui-collapsible__title"; - heading.textContent = "구조물 배치"; - const body = document.createElement("div"); - body.className = "b05-route__panel-body"; - root.append(heading, body); - - const groupSelect = select([]); - const typeSelect = select([]); - // 위치 입력 순서 = 시작 측점 → 기준 측점 → 종료 측점 (2026-08-17 사용자 확정). - // 점형은 기준 측점만 보인다. - // 측점 표기 전역 규칙(2026-08-18): 잔여거리는 [0, 간격) — 넘치면 측점번호로 - // 올려 표시한다(1+30 → 2+10). 정규화는 stationFields가 입력 확정 때 처리. - const startFields = stationFields("시작 측점", () => callbacks.getInterval()); - const anchorFields = stationFields("기준 측점", () => callbacks.getInterval()); - const endFields = stationFields("종료 측점", () => callbacks.getInterval()); - const memoField = document.createElement("input"); - memoField.type = "text"; - memoField.placeholder = "메모(선택)"; - - // 측점 범위(계산 표시) — C군 사면안정처럼 길이 옵션으로 구간이 정해지는 타입은 - // 시작·종료를 직접 입력하지 않고 기준 측점 + 길이로 계산해 보여만 준다 - // (2026-08-19 사용자 지시). 표기는 하단 구조물 목록과 같은 시작 ~ 종료 측점. - const rangeValue = document.createElement("span"); - rangeValue.className = "b05-structure__range-value"; - const rangeWrap = field("측점 범위", rangeValue); - - // 측점은 3행 — 한 행이 [라벨][측점번호][잔여거리](2026-08-17 사용자 지시 2). - // 범위 계산 타입은 [측점 범위][기준 측점]만 보인다(범위 행이 기준 측점 위). - const positionRow = document.createElement("div"); - positionRow.className = "b05-structure__position-row"; - positionRow.append(rangeWrap, startFields.wrap, anchorFields.wrap, endFields.wrap); - - // 기본 인터페이스는 2열 격자(2026-08-17 사용자 지시 1). - const typeRow = document.createElement("div"); - typeRow.className = "b05-structure__grid"; - typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); - - // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. - const optionRow = document.createElement("div"); - optionRow.className = "b05-structure__grid"; - - // 계곡 통과 시설(배관 등)의 부속 옵션 서브폼 — 일반 구조물의 옵션 칸과 같은 - // 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17 - // 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를 - // 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다. - const facilityOptions = createFacilityOptionsForm({ onChange: () => liveCommit() }); + // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. + const form = buildStructuresForm({ + getInterval: () => callbacks.getInterval(), + onFacilityChange: () => liveCommit(), + }); + const { + root, + body, + groupSelect, + typeSelect, + startFields, + anchorFields, + endFields, + memoField, + rangeValue, + rangeWrap, + positionRow, + positionDivider, + optionRow, + actions, + facilityOptions, + primary, + removeButton, + resetButton, + list, + } = form; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 @@ -248,34 +222,6 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu : "—"; } - const primary = document.createElement("button"); - primary.type = "button"; - primary.className = "b05-route__irregular-btn is-primary"; - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "b05-route__irregular-btn is-danger"; - removeButton.textContent = "삭제"; - const resetButton = document.createElement("button"); - resetButton.type = "button"; - resetButton.className = "b05-route__irregular-btn"; - resetButton.textContent = "리셋"; - const actions = document.createElement("div"); - actions.className = "b05-route__irregular-actions"; - actions.append(primary, removeButton, resetButton); - - // 목록은 이 섹션 본문에 붙이지 않는다 — 폼이 길어지면 스크롤 밖으로 밀려 안 보였다. - // 패널(B05_Profile_UI_Panel)이 하단 고정 영역에 배치한다(2026-08-18 사용자 지시). - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - // 사용법 설명 문단은 두지 않는다 — 공간 대비 의미 없음(2026-08-17 사용자 지시, - // 필요 시 별도 설명 페이지로). - - // 측점 그룹과 옵션 나열 사이 구분선(2026-08-17 사용자 지시 2). - const positionDivider = document.createElement("hr"); - positionDivider.className = "b05-structure__divider"; - - body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); - let types: StructureType[] = []; let structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; -- 2.54.0 From 090442bb00bc16229d5aca9a2d874c5d3a351561 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 15:50:12 +0900 Subject: [PATCH 033/441] =?UTF-8?q?fix(B04):=20=EC=9C=A0=EC=97=AD=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=20=EB=8B=A8=EC=88=9C=ED=99=94=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=EC=98=A4=EC=B0=A8=202.0m=20=E2=86=92=201.0m=20(?= =?UTF-8?q?=EA=B2=A9=EC=9E=90=20=ED=95=9C=20=EC=B9=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유역 격자가 1m(DRAINAGE_GRID_SIZE_M)라 경계는 1m 계단이다. 허용오차를 격자 한 칸에 맞추면 형상 손실이 격자 오차 안으로 들어가고, 유역별로 따로 단순화해 생기던 공유 경계 어긋남도 줄어든다. 실측(min_area=0 상태, 용화/S자): - 좌표점 622·829 → 1,749·1,987 - 면적오차 1.86%·1.42% → 1.01%·0.67% - 유역끼리 중첩 1,043㎡·1,373㎡ → 243㎡·373㎡ - 틈 1,184㎡·2,579㎡ → 369㎡·668㎡ 0.5m 이하는 1m 계단 점을 지우지 못해 좌표점만 6배(10,215)로 튄다. 브라우저 실측도 같은 값(API 좌표점 1,750·1,987, 경계 없는 유역 0개)이고 확대해도 계단이 도드라지지 않는다. 테스트 159 passed. Co-Authored-By: Claude Opus 5 (1M context) --- config/config_system.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config/config_system.py b/config/config_system.py index 407c5915..84fdf524 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -389,7 +389,12 @@ DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5. # 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) # 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. -DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "2.0")) +# 격자가 1m(DRAINAGE_GRID_SIZE_M)라 경계는 1m 계단이다 — 허용오차를 격자 한 칸에 맞추면 +# 형상 손실이 격자 오차 안에 들어간다. 2.0m는 유역별로 따로 단순화하는 탓에 공유 경계가 +# 크게 어긋났다(2026-09-03 실측, 용화: 중첩 1,043㎡·틈 1,184㎡ → 1.0m에서 243㎡·369㎡, +# 면적오차 1.86%→1.01%, 좌표점 622→1,749). 0.5m 이하는 계단 점을 못 지워 좌표점만 6배로 +# 튄다(10,215). +DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "1.0")) # 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) # 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_PreProcess/drainage/ 아래에 놓인다. -- 2.54.0 From e29e3cab677f320dbcd8b10f1118b17e42d613a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 16:09:14 +0900 Subject: [PATCH 034/441] =?UTF-8?q?feat(B07):=20=EC=9C=A0=EC=97=AD?= =?UTF-8?q?=EB=8F=84=20=EC=A0=95=EB=B3=B4=ED=91=9C=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0=EB=8F=84=20`n+=200.0`=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토적도만 납품 표기(`120+ 0.0`)로 바꿔 두어 유역도 정보표는 `No.6` 으로 남아 있었다 (2026-09-03 사용자 지시로 통일). `station_plus_label()` 을 토적도 전용 모듈에서 공용 `_Engine_Cad` 로 올려 두 도면이 같은 함수를 쓴다. 종단도 표·횡단도 제목의 `No.n` 표기는 그대로 둔다 — 이번 지시 범위가 아니다. 검증 — 유역도 정보표 첫 줄 `(1) No.6` → `(1) 6+ 0.0`. pytest 383 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_Engine_Cad.py | 15 +++++++++++++++ .../B07_DesignDetail_Engine_Cad_Basin.py | 5 +++-- .../B07_DesignDetail_Engine_Cad_MassHaul.py | 16 +--------------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index 14e47164..93daca79 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -264,6 +264,21 @@ def infer_station_interval(stations: list[dict[str, Any]]) -> float: return max(counts.items(), key=lambda pair: (pair[1], pair[0]))[0] +def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: + """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). + + 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). + """ + safe = interval_m if interval_m > 0 else 1.0 + number = int((chainage_m + 1e-6) // safe) + remainder = chainage_m - number * safe + if remainder >= safe - 0.05: + number += 1 + remainder = 0.0 + gap = " " if decimals == 1 else "" + return f"{number}+{gap}{remainder:.{decimals}f}" + + def station_no_label(chainage_m: float, interval_m: float) -> str: """납품 도면 측점 표기: No.n (비정규 측점은 No.n+잔여거리).""" safe = interval_m if interval_m > 0 else 1.0 diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py index e79f7336..271f75cd 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Basin.py @@ -28,7 +28,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _layer, _text_entity, polyline_entity, - station_no_label, + station_plus_label, table_entity, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( @@ -226,7 +226,8 @@ def _info_box_entities( 선과 문자를 따로 두지 않는다 — 표로 두어야 나중에 DXF의 표로 나갈 수 있다. """ - station = station_no_label(_number(props.get("chainage_m")), interval_m) + # 측점 표기는 납품 도면과 같은 `n+ 0.0` 형식(2026-09-03 사용자 확정 — 토적도와 통일). + station = station_plus_label(_number(props.get("chainage_m")), interval_m) values = { "area": f"{_number(props.get('area_m2')) / 10000.0:.2f} ha", "relief": f"{_format(_number(props.get('relief_m')), 1)} m", diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index 899f52b1..18df4f9b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -33,6 +33,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, infer_station_interval, polyline_entity, + station_plus_label, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( entities_bbox, @@ -113,21 +114,6 @@ _TABLE_LABEL_FONT_SIZE = 3.2 # 행 이름은 본문보다 크게(납품 도면 _TABLE_TOP_GAP = 6.0 # 그래프 최저점과 테이블 사이 간격(mm) -def station_plus_label(chainage_m: float, interval_m: float, decimals: int = 1) -> str: - """납품 도면 측점 표기 — `120+ 0.0` (M.N 은 소수 2자리 `0+16.90`). - - 종전에는 `No.120` 이었다. 납품 도면과 표기를 맞춘다(2026-09-03 사용자 확정). - """ - safe = interval_m if interval_m > 0 else 1.0 - number = int((chainage_m + 1e-6) // safe) - remainder = chainage_m - number * safe - if remainder >= safe - 0.05: - number += 1 - remainder = 0.0 - gap = " " if decimals == 1 else "" - return f"{number}+{gap}{remainder:.{decimals}f}" - - def _number(value: Any, fallback: float = 0.0) -> float: return float(value) if isinstance(value, (int, float)) else fallback -- 2.54.0 From 4fdbcaf09c7ab00fdbd6f1b31b21a6ea7b61187f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 16:43:35 +0900 Subject: [PATCH 035/441] =?UTF-8?q?fix(B07):=20=EC=9E=A5=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EB=9F=89=ED=91=9C=20=EA=B2=B0=ED=95=A8=20?= =?UTF-8?q?3=EA=B1=B4=20=E2=80=94=20=EC=B8=A1=EC=A0=90=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=C2=B7=EA=B3=84=ED=9A=8D=EA=B3=A0=C2=B7=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장 단위 확정 흐름을 실측하다 드러난 것들이다(2026-09-03, 용화 검증본 3장). ① **측점 구분 없이 역추출** — `_table_values_from_cells()` 가 도면의 표를 전부 훑어 마지막 값을 모든 측점에 넣었다(실측: 4개 측점 전부 지반고 837.21, 도면에는 840.87·840.33·836.45·837.21 로 제대로 그려져 있었음). 표 엔티티 id 가 `uuid5("{도면id}:qtable")` 이라 측점을 되짚을 수 있어 그 표만 읽는다. 옛 도면은 id 가 안 맞으므로 종전처럼 전부 훑는 폴백을 남긴다. ② **계획고가 빈 채로 나감** — 장 배치 입력의 원본에는 계획고가 없어 계획고·절토고·성토고 세 칸이 통째로 비었다(21개 항목 중 지반고 하나만 채워짐). `_quantity_table()` 이 횡단 설계(`design.design_elevation_m`)도 보게 했다. ③ **확정 해제가 404** — [수정]이 도면 목록을 **설계값 없이** 만들어 장 나눔이 달라졌고, 방금 확정한 장 id 가 목록에 없어 되돌릴 수 없었다. 목록 조회·확정과 같은 인자를 쓴다. 검증(공용 브라우저·실동작) — 확정 해제 404 → **200**, 표 값 `planned=840.87 fill=0.00` 등 측점마다 다름, manifest 수량표가 측점별로 갈림(220 · 240 · 260 · 264 · 280 각각 4/21 항목 채움 — 나머지 17칸은 사용자가 CAD 에서 채우는 자리). 시험 뒤 확정은 모두 해제해 원상복구. pytest 383 passed·17 skipped, ruff format 무변경. Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Engine_Cad_Table.py | 22 ++++++++++++++++--- B07_DesignDetail/B07_DesignDetail_Router.py | 7 ++++-- .../B07_DesignDetail_Router_Support.py | 14 +++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index 20844ec4..d0f1fce7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -223,12 +223,28 @@ def _cross_table_entities( ] -def _table_values_from_cells(entities: list[Any]) -> dict[str, float | None]: - """표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다.""" +def _table_values_from_cells( + entities: list[Any], drawing_id: str | None = None +) -> dict[str, float | None]: + """표 객체의 칸에서 값을 읽는다. 칸의 key가 어느 수량인지 알려 준다. + + **한 도면에 표가 여럿이면 그 측점 것만 읽는다**(장은 측점 4~6개를 담는다). + 표 엔티티 id 는 `uuid5(f"{도면id}:qtable")` 이라 측점을 되짚을 수 있다. 이 걸름이 + 없던 동안 장 확정이 **모든 측점에 마지막 표 값을 넣었다**(2026-09-03 실측: 4개 측점이 + 전부 지반고 837.21 — 도면에는 840.87·840.33·836.45·837.21 로 제대로 그려져 있었다). + id 로 못 찾으면(옛 도면) 종전처럼 전부 훑는다. + """ + wanted = str(uuid5(_ENTITY_NS, f"{drawing_id}:qtable")) if drawing_id else None + if wanted is not None and not any( + isinstance(entity, dict) and entity.get("id") == wanted for entity in entities + ): + wanted = None table: dict[str, float | None] = {} for entity in entities: if not isinstance(entity, dict) or entity.get("type") != "Table": continue + if wanted is not None and entity.get("id") != wanted: + continue shape = entity.get("shapeData") rows = shape.get("cells") if isinstance(shape, dict) else None if not isinstance(rows, list): @@ -281,7 +297,7 @@ def extract_quantity_table( entities = drawing.get("entities") if not isinstance(entities, list): return None - table = _table_values_from_cells(entities) + table = _table_values_from_cells(entities, drawing_id) if not table: table = _table_values_from_texts(drawing_id, entities) if not table: diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index b47861b8..a46bba9a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -462,7 +462,11 @@ async def invalidate_design_drawing( """확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다.""" try: route_id, project_root, longitudinal_path = await _confirmed_source(project_id) - items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path) + # 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로, + # 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다 + # (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가). + designs = await _designs_by_chainage(route_id) + items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs) if drawing_id not in {item.id for item in items}: raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.") await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id) @@ -472,7 +476,6 @@ async def invalidate_design_drawing( if cross_match: stale = [int(cross_match.group(1))] elif CROSS_SHEET_ID.fullmatch(drawing_id): - designs = await _designs_by_chainage(route_id) plan = await asyncio.to_thread( _cross_sheet_plan, project_root, longitudinal_path, designs ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 5d0396d8..6d69433e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -466,12 +466,18 @@ def _cross_sheet_plan( return plan_cross_sheets(blocks) -def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: +def _quantity_table( + source: dict[str, Any], design: dict[str, Any] | None = None +) -> dict[str, float | None]: """횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다 (납품 양식 키). center_z→지반고, design_elevation_m→계획고, 절토고/성토고는 파생 초기값. 나머지 항목은 source["quantities"]에 같은 키가 있으면 읽고 없으면 None으로 두어 CAD 테이블에서 사용자가 채운다. + + **계획고는 횡단 설계(design)에도 있다** — 장 배치 입력의 원본에는 그 값이 없어 + 계획고·절토고·성토고 세 칸이 통째로 비어 나갔다(2026-09-03 실측: 장 확정 시 21개 + 항목 중 지반고 하나만 채워짐). 원본에 없으면 설계에서 읽는다. """ def num(value: Any) -> float | None: @@ -479,6 +485,8 @@ def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]: ground = num(source.get("center_z")) planned = num(source.get("planned_elevation_m", source.get("design_elevation_m"))) + if planned is None and isinstance(design, dict): + planned = num(design.get("design_elevation_m")) cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {} @@ -568,7 +576,7 @@ def _cross_section_input( "source": source, "design": design, "design_line": _cross_design_line(longitudinal_path, source, design), - "quantity_table": _quantity_table(source), + "quantity_table": _quantity_table(source, design), "title": station_no_label(float(source.get("chainage_m", chainage)), interval), } @@ -697,7 +705,7 @@ def _read_drawing( source = _read_json(path) label = str(source.get("label") or drawing_id) design_line = _cross_design_line(longitudinal_path, source, stored_design) - quantity_table = _quantity_table(source) + quantity_table = _quantity_table(source, stored_design) # 수량표 제목행 No. 표기: 종단 측점 간격 기준 (납품 도면 양식) longitudinal = _read_json(longitudinal_path) interval = infer_station_interval(longitudinal.get("stations") or []) -- 2.54.0 From 0f5b4e4df7935a5da03e78472a427c15541467d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 18:31:54 +0900 Subject: [PATCH 036/441] auto: 2026-09-03 18:31 (ESD_LAPTOP) --- .../B07_DesignDetail_UI_Cad_Structures.ts | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index 89a249f0..fa0cef2d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -323,20 +323,55 @@ function circlePoints(center: XY, radius: number): XY[] { return points; } +/** + * `rect` → 닫힌 네 모서리 점열(회전 transform 적용). + * + * 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에 + * `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭 + * 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은 + * 무시한다 — 도면에서는 각진 칸으로 충분하다. + */ +function rectPoints(element: SVGElement): XY[] { + const x = attr(element, "x"); + const y = attr(element, "y"); + const width = attr(element, "width"); + const height = attr(element, "height"); + const corners: XY[] = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + [x, y], + ]; + const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec( + element.getAttribute("transform") ?? "", + ); + if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy)); + const angle = (Number(rotate[1]) * Math.PI) / 180; + const [ox, oy] = [Number(rotate[2]), Number(rotate[3])]; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return corners.map(([cx, cy]) => { + const dx = cx - ox; + const dy = cy - oy; + return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos); + }); +} + /** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const [fx0, , fx1] = frame; const entities: Entity[] = []; const segments: XY[][] = []; const inside = (px: number): boolean => px >= fx0 && px <= fx1; - const nodes = root.querySelectorAll("polygon, polyline, line, circle, text"); + const nodes = root.querySelectorAll("polygon, polyline, line, circle, rect, text"); for (const element of Array.from(nodes)) { if (element.closest(SKIP_SELECTOR)) continue; const tag = element.tagName.toLowerCase(); // 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다. const clip = clipPolygonOf(element); - if (tag === "polygon" || tag === "polyline") { - const points = parsePoints(element); + if (tag === "polygon" || tag === "polyline" || tag === "rect") { + const points = tag === "rect" ? rectPoints(element) : parsePoints(element); if (tag === "polygon" && points.length > 2) points.push(points[0]); const runs = clip ? clipPointsToPolygon(points, clip) : [points]; for (const run of runs) { -- 2.54.0 From 0ce6b43bdc218b5c5fb6f0d5be38698d382746e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:02:03 +0900 Subject: [PATCH 037/441] =?UTF-8?q?fix(B05):=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B3=84=ED=9A=8D=EC=84=A0=EC=97=90=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EA=B3=A1=EC=84=A0=20=EB=B3=B5=EC=9B=90=20?= =?UTF-8?q?=E2=80=94=20=ED=98=B8=EA=B0=80=20=EC=A7=80=EB=B0=98=EA=B3=A0?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=EA=B3=BC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=BC=AD=EC=A7=93=EC=A0=90=20=EB=B0=98=EB=B3=B5=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `3651ad98` 의 `only_explicit_curves=True` 로 전체 측점 폴리라인에서 자동 종단곡선이 전부 꺼져, B05 종단 그래프의 계획선이 직선만으로 그려졌음. - `design_ground_following_profile()` — 변화점마다 기본 L(15m) 종단곡선을 다시 삽입. 대칭 종단곡선은 꼭짓점을 지나지 않으므로(중앙종거 |A|·L/8), 호와 측점 세로선의 교점이 지반고가 되도록 꼭짓점 표고를 최대 12회 반복 보정 — `design_pipe_anchored_profile` 과 같은 방식. 결과: 호의 중심이 측점 세로선 위, 좌우 직선과 접선, 계획고 = 지반고. - `rebuild_alignment_profile()` — `GROUND_POLYLINE_BASIS` 곡선 생략 예외 제거. 저장된 `base_pvi` 가 보정된 꼭짓점이라 편집 델타를 지우면 초기선으로 그대로 복귀함. - 프론트는 payload `only_explicit_curves` 를 그대로 따르므로 코드 변경 없음. - 호 크기 기준은 현행 유지(2026-09-03 사용자 결정) — L 고정·R 은 결과값, 법정 최소반경 미만은 경고만. 검증: `tmp/tests/test_b05_ground_profile_curves.py` 4건 + 전체 173건 통과. route 127 실데이터 오프라인 산출에서 호 126개(저장분 0개), 측점 계획고−지반고 최대 0.063mm. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_Engine_Grade_Profile.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index c38a6a8f..fc7e51db 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -153,11 +153,13 @@ def design_ground_following_profile( ) -> tuple[dict[str, Any], dict[str, Any]]: """전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선. - 규칙(2026-09-02 사용자 확정): - - **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다. - - **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을 - 틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다 - (`build_alignment(only_explicit_curves=True)`). + 규칙(2026-09-02 사용자 확정 + 2026-09-03 종단곡선 복원): + - **모든 측점이 변화점**이고 그 자리 계획고는 지반고 그대로다. 절·성토가 거의 0이다. + - **변화점마다 종단곡선을 넣는다.** 직선과 직선 사이에는 반드시 호가 있고, 호는 + 변화점 대칭이라 **호의 중심이 측점 세로선 위**에 놓이며 좌우 직선과 접선을 이룬다. + - 대칭 종단곡선은 꼭짓점을 지나지 않으므로 **호와 측점 세로선의 교점이 지반고**가 + 되도록 꼭짓점 표고를 중앙종거만큼 밀어내며 반복 보정한다 + (`design_pipe_anchored_profile` 과 같은 방식). - 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다 (기존 정책과 같다). @@ -204,6 +206,19 @@ def design_ground_following_profile( if note: warnings.append(note) + # 대칭 종단곡선은 꼭짓점을 지나지 않는다(중앙종거 |A|·L/8) — 보정 없이 곡선을 넣으면 + # 계획고가 지반고에서 뜬다. 호가 측점 세로선과 만나는 점이 지반고가 되도록 꼭짓점을 + # 밀어내며, 표고가 움직이면 대수차도 변하므로 수렴할 때까지 되풀이한다. + target = base_z.copy() + for _ in range(12): + curves, _curve_warnings = build_curves(base_s, base_z, policy) + error = target - evaluate(base_s, base_z, curves, base_s) + error[0] = 0.0 + error[-1] = 0.0 + if float(np.max(np.abs(error))) < 1e-4: + break + base_z = base_z + error + alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -212,7 +227,6 @@ def design_ground_following_profile( stations=stations, policy=policy, edits=dict(edits or {}), - only_explicit_curves=True, ) warnings.extend(alignment["warnings"]) balanced = bool(alignment["balance"]["within_tolerance"]) @@ -449,10 +463,6 @@ def rebuild_alignment_profile( policy = AlignmentPolicy.from_dict(stored.get("policy") or {}) previous = (longitudinal.get("design_profiles") or [{}])[0] - # 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야 - # 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02). - only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS - alignment = build_alignment( base_s=base_s, base_z=base_z, @@ -461,7 +471,6 @@ def rebuild_alignment_profile( stations=list(longitudinal.get("stations") or []), policy=policy, edits=edits, - only_explicit_curves=only_explicit, ) criteria = previous.get("criteria") or {} options = GradeDesignOptions( -- 2.54.0 From c21b68f2ced2042de7daffc9fca2e4f53eb2b59b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:16:46 +0900 Subject: [PATCH 038/441] =?UTF-8?q?@=20feat(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=A0=9C=EB=AA=A9=ED=96=89=EC=97=90=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=EC=82=AC=EB=A9=B4=20=EB=B3=B8=EB=9E=98=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=EA=B8=B8=EC=9D=B4=20=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사면 미교차 경고가 붙는 제목행 우측 끝에 성토사면 경사길이를 표기함. 값은 정본 설계선(`design_line`) 기준이라 기슭막이·집수정이 사면을 끊기 전 **본래 길이**이고, 양측 성토는 좌·우 두 값을 다 냄. - `fillSlopeLengths()` 신설 — 기존 `protectedSpan`·`meetOffset` 재사용, 성토측만 노견 끝(측구가 있으면 측구 바깥)에서 사면 끝까지 `run × √(1+1/n²)`. - 미교차 측점은 `≥` 하한값 — 추세 외삽은 지반이 사면과 나란한 자리에서 수십 m 씩 튀어(740m 측점 101.53m) 길이 표기에 못 씀. - `meetOffset` 교차점을 두 걸음 사이 선형보간으로 냄 — 반폭 맞춤(1m 올림)에는 영향이 없고 길이 표기가 탐색 간격만큼 튀는 것만 막음. Co-Authored-By: Claude Opus 5 (1M context) @ --- .../B06_Section_UI_Cross_Card_Chrome.ts | 32 ++++++++++ B06_Section/B06_Section_UI_Cross_Fit.ts | 59 ++++++++++++++++++- B06_Section/B06_Section_UI_Style_Cross.css | 6 ++ ui_template/ui_template_locale_b2.ts | 6 ++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 4d57f093..4f91214c 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -13,8 +13,36 @@ import { buildRockBoundaryControl, sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; +import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common"; +/** + * 성토사면 경사길이 표기 칸 — 성토측이 없으면 null. + * + * 값은 **구조물이 끊기 전 본래 사면**(정본 설계선 기준)이라, 기슭막이·집수정이 선 측점도 + * 표준 횡단의 사면 길이를 그대로 보여 준다(2026-09-03 사용자 지시). + */ +function fillSlopeInfo(section: CrossSection): HTMLElement | null { + const lengths = fillSlopeLengths(section); + const sides = (["left", "right"] as const).filter((side) => lengths[side] !== null); + if (!sides.length) return null; + const both = sides.length > 1; + const info = document.createElement("span"); + info.className = "b06-cross-card__fillslope"; + info.textContent = `${L("B06_Cross_FillSlope")} ${sides + .map((side) => { + const length = lengths[side] as FillSlopeLength; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + const value = `${length.open ? "≥" : ""}${length.lengthM.toFixed(2)}m`; + if (!both) return value; + const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right"); + return `${label} ${value}`; + }) + .join(" · ")}`; + info.title = L("B06_Cross_FillSlope_Tip"); + return info; +} + /** * 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지). * @@ -67,6 +95,10 @@ export function appendCardHeader( ); meta.append(kind); } + // 성토사면 경사길이는 **행 우측 끝**(2026-09-03 사용자 지시) — meta 가 제목행의 오른쪽 + // 끝이므로 그 마지막 칸에 붙인다. + const fillSlope = fillSlopeInfo(section); + if (fillSlope) meta.append(fillSlope); // 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번). modePill.classList.add("b06-cross-card__mode--right"); diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index 3814d143..ac6cd84a 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -58,6 +58,58 @@ export function toeFitHalfWidth(section: CrossSection): number | null { return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); } +/** + * 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시). + * + * 「본래」 = 구조물(기슭막이·집수정)이 사면을 끊기 전 표준 횡단 기준. 구조물은 프론트 + * 오버레이라 백엔드 `design_line`을 바꾸지 않으므로, 그 선으로 재면 곧 본래 길이다. + * 구간은 노견 끝(그 측에 측구가 있으면 측구 바깥)부터 설계선이 원지반과 처음 만나는 + * 점까지이고, 그 사이 성토선은 1:n 직선이라 수평 성분만으로 경사길이가 나온다. + */ +export interface FillSlopeLength { + /** 사면 경사길이(m). 미교차(`open`)면 계산 반폭까지의 **하한값**이다. */ + lengthM: number; + /** 설계선 끝(계산 반폭)까지 원지반과 만나지 못한 사면 — 길이를 다 재지 못했다. */ + open: boolean; +} + +export function fillSlopeLengths(section: CrossSection): { + left: FillSlopeLength | null; + right: FillSlopeLength | null; +} { + const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = { + left: null, + right: null, + }; + const design = section.design; + if (!design) return lengths; + const groundAt = groundInterpolator(section.samples); + const designAt = designInterpolator(design.design_line); + const edges = design.road_edges; + if (!groundAt || !designAt || !edges) return lengths; + const lineOffsets = design.design_line.map((point) => point.offset_m); + if (lineOffsets.length < 2) return lengths; + const { protectMax, protectMin } = protectedSpan(design, edges); + const slant = Math.hypot(1, 1 / Math.max(design.fill_slope_ratio, 1e-6)); + for (const side of ["left", "right"] as const) { + const mode = design.section_mode; + if (mode === "both_cut" || mode === `${side}_cut`) continue; + const outward = side === "left" ? 1 : -1; + const start = side === "left" ? protectMax : protectMin; + const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets); + // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가 + // 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다 + // (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지). + const meet = meetOffset(designAt, groundAt, start, limit, outward); + const end = Math.abs(meet) > Math.abs(limit) ? limit : meet; + lengths[side] = { + lengthM: Math.abs(end - start) * slant, + open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M, + }; + } + return lengths; +} + /** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ function protectedSpan( design: CrossDesign, @@ -98,7 +150,12 @@ function meetOffset( const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span); const diff = designAt(offset) - groundAt(offset); if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset; - if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; + if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) { + // 두 걸음 사이를 선형보간한다 — 반폭 맞춤에는 영향이 없지만(1m 올림), 같은 교차점을 + // 길이로 읽는 성토사면 표기가 탐색 간격(0.1m)만큼 튀는 것을 막는다(2026-09-03). + const previous = offset - outward * SCAN_STEP_M; + return previous + (offset - previous) * (previousDiff / (previousDiff - diff)); + } previousDiff = diff; } return extrapolateMeet(designAt, groundAt, limitOffset, outward); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index ee013e19..2cf43267 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -238,6 +238,12 @@ white-space: nowrap; } +/* 성토사면 경사길이 — 제목행 우측 끝(2026-09-03). meta 의 마지막 칸이라 우측 맞춤이다. */ +.b06-cross-card__fillslope { + color: var(--color-text-muted); + white-space: nowrap; +} + /* 지반유형 세그먼트(D-6): 제목행 1행 내 인라인 배치. 좌측 구분선(3번). */ .b06-design__seg--header { flex: 0 0 auto; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 893a1a3f..5877b4ab 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -348,6 +348,12 @@ export const ui_locales_b2 = { "사면이 계산 반폭 끝까지 원지반과 만나지 않아 절·성토 면적이 그 자리에서 잘렸습니다. 유토곡선·수량도 잘린 값을 씁니다.", "The slope never meets existing ground within the computed half-width, so the cut/fill area is truncated there. The mass haul curve and quantities use the truncated value.", ], + /* 성토사면 경사길이 — 구조물이 사면을 끊기 전 **본래** 길이(2026-09-03 사용자 지시). */ + B06_Cross_FillSlope: ["성토사면", "Fill slope"], + B06_Cross_FillSlope_Tip: [ + "노견 끝(그 측에 측구가 있으면 측구 바깥)부터 성토 사면이 원지반과 만나는 곳까지의 경사길이입니다. 기슭막이·집수정이 사면을 끊기 전 본래 길이이며, 양측 성토면 좌·우를 함께 적습니다. 5m를 넘으면 옹벽·석축 등이 필요합니다(성토_비탈면 §2). 「≥」는 사면이 계산 반폭 안에서 원지반과 만나지 못해 거기까지만 잰 하한값입니다.", + "Slant length of the fill slope, from the shoulder edge (outside the ditch if that side has one) to where it meets existing ground. It is the original slope before any revetment or catch basin cuts it short, and both sides are shown when the section is filled on both. Over 5 m a retaining structure is required. A leading ≥ marks a lower bound: the slope never meets ground within the computed half-width, so only that much could be measured.", + ], B06_MassHaul_CutNatural: ["절토(자연)", "Cut (natural)"], B06_MassHaul_CutCompacted: ["절토(다짐환산)", "Cut (compacted)"], B06_MassHaul_Fill: ["성토", "Fill"], -- 2.54.0 From f772401b0665db530160518e1925bcf2627a04a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:28:10 +0900 Subject: [PATCH 039/441] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20pill=20?= =?UTF-8?q?=EC=9D=84=20=EB=B0=B0=EC=88=98=EA=B4=80=20=ED=91=9C=EA=B8=B0?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관종·관경(`파형강관 D1200`) 대신 표시 이름 `배수관` 하나만 적음 — 2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙과 같은 표기. 관종·관경은 툴팁에 남김. 판정은 `PIPE_TYPES` 목록으로 하고 저장 라벨은 손대지 않음 — 재진입 이관이 라벨 문자열로 구조물 종류를 되짚기 때문임. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Card_Chrome.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 4f91214c..a8300581 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -6,6 +6,8 @@ * 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다. * ========================================================================== */ +import { PIPE_TYPES } from "@config/config_frontend"; +import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import { @@ -80,11 +82,16 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } - if (section.structure) { + const structureName = section.structure; + if (structureName) { const structure = document.createElement("span"); structure.className = "b06-cross-card__structure"; - structure.textContent = section.structure; - structure.title = `구조물: ${section.structure}`; + // 배수관은 관종·관경(`파형강관 D1200`)이 아니라 표시 이름 하나로 적는다 + // (2026-09-03 사용자 지시 — 2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙과 같다). + // 관종·관경은 툴팁에 남긴다. + const isPipe = PIPE_TYPES.some((kind) => structureName.startsWith(`${kind} D`)); + structure.textContent = isPipe ? PIPE_DISPLAY_NAME : structureName; + structure.title = `구조물: ${structureName}`; meta.append(structure); } // 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시). -- 2.54.0 From b908a0b10d15b229a64d8e06e9e4acfa077b8d6c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:35:21 +0900 Subject: [PATCH 040/441] =?UTF-8?q?feat(B06):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=EB=8F=84=20=EB=B0=B0=EC=88=98=EA=B4=80=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관종·관경(`파형강관 D1200`) 대신 `배수관` 을 적음 — 횡단 카드와 같은 규칙. 판정을 `structureDisplayName()`(`_UI_Section_Common`) 한 곳으로 모아 두 렌더러가 같은 문자열을 쓰게 함. 저장 라벨은 그대로 — 재진입 이관이 라벨로 종류를 되짚음. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Card_Chrome.ts | 15 ++++++++------- B06_Section/B06_Section_UI_Longitudinal.ts | 4 +++- B06_Section/B06_Section_UI_Section_Common.ts | 13 +++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index a8300581..d7cd8c5c 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -6,8 +6,6 @@ * 만든다. 여기 있는 것은 전부 **읽어서 붙이는 값**이라 카드 상태(줌·선택)를 건드리지 않는다. * ========================================================================== */ -import { PIPE_TYPES } from "@config/config_frontend"; -import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import { @@ -16,7 +14,12 @@ import { sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; -import { type DesignChangeHandler, L, stationLabel } from "./B06_Section_UI_Section_Common"; +import { + type DesignChangeHandler, + L, + stationLabel, + structureDisplayName, +} from "./B06_Section_UI_Section_Common"; /** * 성토사면 경사길이 표기 칸 — 성토측이 없으면 null. @@ -87,10 +90,8 @@ export function appendCardHeader( const structure = document.createElement("span"); structure.className = "b06-cross-card__structure"; // 배수관은 관종·관경(`파형강관 D1200`)이 아니라 표시 이름 하나로 적는다 - // (2026-09-03 사용자 지시 — 2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙과 같다). - // 관종·관경은 툴팁에 남긴다. - const isPipe = PIPE_TYPES.some((kind) => structureName.startsWith(`${kind} D`)); - structure.textContent = isPipe ? PIPE_DISPLAY_NAME : structureName; + // (2026-09-03 사용자 지시). 관종·관경은 툴팁에 남긴다. + structure.textContent = structureDisplayName(structureName); structure.title = `구조물: ${structureName}`; meta.append(structure); } diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 5cccae88..0db4f492 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -17,6 +17,7 @@ import { longitudinalMaxChainage, niceTickStep, stationLabel, + structureDisplayName, svgElement, svgText, validElevation, @@ -327,7 +328,8 @@ export function createLongitudinalProfile( // 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시). if (station.structure) { structureLabels.push( - svgText(station.structure, { + // 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙). + svgText(structureDisplayName(station.structure), { x: stationX, y: heightPx - padBottom - 5, "text-anchor": "middle", diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 9f8016fb..09a7c62a 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -7,6 +7,8 @@ * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. * ========================================================================== */ +import { PIPE_TYPES } from "@config/config_frontend"; +import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations"; import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import type { DesignProfile, @@ -56,6 +58,17 @@ export interface YScaleOptions { globalMaxElevation: number; } +/** + * 구조물 라벨의 **표시 이름** — 배수관은 관종·관경(`파형강관 D1200`) 대신 `배수관` 하나로 + * 적는다(2026-08-17 확정한 `PIPE_DISPLAY_NAME` 규칙, 2026-09-03 종·횡단 표기에 적용). + * + * 저장 라벨은 그대로 두어야 한다 — 재진입 이관(`B05_Profile_Structures_Migration`)이 라벨 + * 문자열로 구조물 종류를 되짚는다. 그래서 바꾸는 것은 화면에 찍는 순간뿐이다. + */ +export function structureDisplayName(label: string): string { + return PIPE_TYPES.some((kind) => label.startsWith(`${kind} D`)) ? PIPE_DISPLAY_NAME : label; +} + export function validElevation( sample: SectionSample, ): sample is SectionSample & { elevation_m: number } { -- 2.54.0 From c19af779e4d6cb67bbbbf25a0202829055309cb0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:42:31 +0900 Subject: [PATCH 041/441] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EA=B3=84=EC=82=B0=20TS=20=EB=AF=B8?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=EC=97=90=EC=84=9C=20=EC=A0=88=C2=B7=EC=84=B1?= =?UTF-8?q?=ED=86=A0=20=EB=A9=B4=EC=A0=81=20=EC=82=B0=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 조작 중 계산은 브라우저 안에서 끝나야 함(2026-09-03 사용자 확정). 계획선을 만질 때마다 전 측점 횡단 계산이 서버로 나가 조작 속도를 왕복이 지배했음. - `common_util_cross_design_areas.ts` — `B06_Section_Engine_Areas.py` 미러 (사다리꼴 적분 + 절토 토사/암반 분리). - `common_util_cross_design.ts` — `B06_Section_Engine_Design.py` 미러 (표준횡단 설계선 구성 + 단면적). config 수치는 복제하지 않고 `sections/context.standard_cross_section` 을 입력으로 받음. 두 파일 머리에 짝 파일·회귀 테스트를 가리키는 경고 블록을 둠 — 한쪽만 고치면 두 화면 값이 갈림. 아직 어느 화면도 이 모듈을 부르지 않음(다음 커밋에서 연결). Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_cross_design.ts | 717 ++++++++++++++++++ common_util/common_util_cross_design_areas.ts | 97 +++ 2 files changed, 814 insertions(+) create mode 100644 common_util/common_util_cross_design.ts create mode 100644 common_util/common_util_cross_design_areas.ts diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts new file mode 100644 index 00000000..9c1131a0 --- /dev/null +++ b/common_util/common_util_cross_design.ts @@ -0,0 +1,717 @@ +/* ============================================================================= + * common_util_cross_design.ts + * B06 측점 표준횡단 설계 계산 — **브라우저 판**. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ + * 짝: `B06_Section/B06_Section_Engine_Design.py` (`compute_cross_design`) + * 면적 적분은 `common_util_cross_design_areas.ts` ↔ `B06_Section_Engine_Areas.py`. + * 회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다: + * `tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것. + * + * ── 왜 같은 계산이 두 벌인가 (2026-09-03 사용자 확정) ──────────────── + * 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야 한다**. 조작은 세션 + * 캐시에 쌓이고 화면은 즉시 따라오며, 서버는 [저장]·[확정]에서만 부른다. 계획고가 바뀔 + * 때마다 서버에 전 측점 횡단을 물으면 왕복이 조작 속도를 지배한다(2026-09-03 실측 보고). + * 계획선 선형(`B05_Profile_Engine_Grade_Alignment.py` ↔ `B05_Profile_UI_Profile_Alignment.ts`) + * 이 이미 같은 이유로 1:1 미러다. + * + * ── 갈라지지 않게 하는 규칙 ────────────────────────────────────────── + * 1. **config 상수를 여기에 복제하지 않는다.** 표준단면 수치는 서버가 config 에서 내려 + * 주는 `sections/context.standard_cross_section` 을 입력으로 받는다. 아래 상수는 + * config 의 *열거값*(지반유형→프리셋 등)뿐이며 그마저 짝 파일과 나란히 둔다. + * 2. 반올림·경계 판정 상수까지 파이썬과 같은 값을 쓴다. + * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. + * ========================================================================== */ + +import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; + +/** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ +const GROUND_TYPE_PRESET: Record = { + soil: "soil", + ripping_rock: "rock", + blasting_rock: "rock", +}; +/** 단면유형. 짝: config `SECTION_MODES`. 좌=양(+)offset, 우=음(-)offset. */ +const SECTION_MODES = ["left_cut", "right_cut", "both_cut", "both_fill"]; +/** 짝: config `SECTION_DITCH_SIDES` / `SECTION_DITCH_TYPES`. */ +const DITCH_SIDES = ["left", "right"]; +const DITCH_TYPES = ["standard", "l_type"]; + +/** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */ +const SLOPE_CLOSE_TOLERANCE_M = 0.01; +/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ +const MARCH_STEP_M = 0.05; +const KNEE_MAX_M = 200; +const CROSS_MAX_M = 500; + +export interface CrossGroundSample { + offset_m?: number | null; + elevation_m?: number | null; + valid?: boolean; +} + +/** `sections/context.standard_cross_section` 한 그룹의 모양(서버가 config 에서 내려 준다). */ +export interface StandardGroupSpec { + road_width_m?: number; + shoulder_left_m?: number; + shoulder_right_m?: number; + ditch?: { top_width_m?: number; bottom_width_m?: number; depth_m?: number }; + ditch_l_type?: { width_m?: number; depth_m?: number }; + cross_slope_pct?: { min?: number; max?: number }; + fill_slope_ratio?: number; + cut_slope_ratio?: number; + pavement_thickness_m?: number; +} + +/** 프리셋 키(soil/rock/paved) → 그룹 값. 세션 편집값도 같은 모양이다. */ +export type StandardCrossSectionSpec = Record; + +export interface CrossDesignOptions { + groundType: string; + sectionMode: string; + ditchSide?: string | null; + ditchType?: string; + paved?: boolean; + /** 표준단면 수치(필수) — config 기본값 또는 그 위에 얹은 세션 편집값. */ + standard: StandardCrossSectionSpec; + rockBoundaryOffsetM?: number | null; + twoStageSlope?: boolean; + ditchEnabled?: boolean | null; + /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ + surfaceDropM?: number; +} + +export interface CrossDesignEdge { + offset_m: number; + elevation_m: number; +} + +export interface CrossDesignResult { + ground_type: string; + geometry_preset: string; + section_mode: string; + ditch_side: string; + ditch_type: string | null; + cut_slope_ratio: number; + soil_cut_slope_ratio: number; + two_stage_slope: boolean; + fill_slope_ratio: number; + roadbed_width_m: number; + carriageway_width_m: number; + cross_slope_pct: number; + ditch: Record; + ditch_enabled: boolean; + paved: boolean; + road_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; + carriageway_edges: { left: CrossDesignEdge; right: CrossDesignEdge }; + design_elevation_m: number; + cut_area_m2: number; + cut_soil_area_m2: number; + cut_rock_area_m2: number; + cut_rock_kind: string | null; + fill_area_m2: number; + slope_unclosed: boolean; + fill_ground_slope: number | null; + ditch_area_m2: number; + design_line: CrossDesignEdge[]; + surface_drop_m?: number; + pavement_thickness_m?: number; + rock_boundary_offset_m?: number; +} + +/** 짝: 파이썬 `round(value, 4)`. */ +function round4(value: number): number { + return Math.round(value * 1e4) / 1e4; +} + +/** 짝: `_as_float` — 손상값·음수면 fallback. */ +function asFloat(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return fallback; + return parsed >= 0 ? parsed : fallback; +} + +interface ResolvedGroup { + road_width_m: number; + shoulder_left_m: number; + shoulder_right_m: number; + ditch_top_width_m: number; + ditch_bottom_width_m: number; + ditch_depth_m: number; + l_ditch_width_m: number; + l_ditch_depth_m: number; + cross_slope_pct: number; + fill_slope_ratio: number; + cut_slope_ratio: number; + pavement_thickness_m: number; +} + +/** + * 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게 + * 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다. + */ +function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): ResolvedGroup { + const group = standard[presetKey]; + if (!group) { + throw new Error(`표준횡단 설정에 '${presetKey}' 그룹이 없어 횡단 설계를 계산할 수 없습니다.`); + } + const ditch = group.ditch ?? {}; + const lDitch = group.ditch_l_type ?? {}; + const slope = group.cross_slope_pct ?? {}; + return { + road_width_m: asFloat(group.road_width_m, 0), + shoulder_left_m: asFloat(group.shoulder_left_m, 0), + shoulder_right_m: asFloat(group.shoulder_right_m, 0), + ditch_top_width_m: asFloat(ditch.top_width_m, 0), + ditch_bottom_width_m: asFloat(ditch.bottom_width_m, 0), + ditch_depth_m: asFloat(ditch.depth_m, 0), + // 짝 파일의 `base.get("width_m", 0.5)`와 같은 최후 기본값. + l_ditch_width_m: asFloat(lDitch.width_m, 0.5), + l_ditch_depth_m: asFloat(lDitch.depth_m, 0.1), + // 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값). + cross_slope_pct: asFloat(slope.min, 0), + fill_slope_ratio: asFloat(group.fill_slope_ratio, 0), + cut_slope_ratio: asFloat(group.cut_slope_ratio, 0), + pavement_thickness_m: asFloat(group.pavement_thickness_m, 0.2), + }; +} + +/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ +function sideRole(sectionMode: string): [string, string] { + if (sectionMode === "left_cut") return ["cut", "fill"]; + if (sectionMode === "right_cut") return ["fill", "cut"]; + if (sectionMode === "both_cut") return ["cut", "cut"]; + if (sectionMode === "both_fill") return ["fill", "fill"]; + throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); +} + +/** 짝: `_resolve_ditch_side`. */ +function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string { + if (sectionMode === "left_cut") return "left"; + if (sectionMode === "right_cut") return "right"; + if (ditchSide && DITCH_SIDES.includes(ditchSide)) return ditchSide; + return "left"; +} + +/** 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). */ +function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number { + return (offsetM: number): number => { + if (offsetM <= valid[0][0]) return valid[0][1]; + if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1]; + for (let index = 1; index < valid.length; index += 1) { + const [x1, z1] = valid[index]; + if (offsetM > x1) continue; + const [x0, z0] = valid[index - 1]; + const span = x1 - x0; + if (span <= 0) return z1; + return z0 + (z1 - z0) * ((offsetM - x0) / span); + } + return valid[valid.length - 1][1]; + }; +} + +/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ +class SectionGeometry { + halfRoad: number; + leftExtent: number; + rightExtent: number; + zCenter: number; + cutRatio: number; + fillRatio: number; + leftRole: string; + rightRole: string; + ditchSide: string; + soilCutRatio: number; + twoStage: boolean; + ditchType: string; + slopePerOffset: number; + hasDitch: boolean; + ditchPoints: Array<[number, number]> = []; + private groundAt: ((offsetM: number) => number) | null; + private rockOffset: number; + private rockKnee = new Map(); + private cutCross = new Map(); + private fillCross = new Map(); + + constructor(params: { + designElevationM: number; + group: ResolvedGroup; + sectionMode: string; + ditchSide: string; + ditchType: string; + crossSlopePct: number; + groundAt: ((offsetM: number) => number) | null; + soilCutRatio: number | null; + rockBoundaryOffsetM: number | null; + twoStageSlope: boolean; + ditchEnabled: boolean | null; + }) { + const { group } = params; + const halfRoad = group.road_width_m / 2; + this.halfRoad = halfRoad; + this.leftExtent = halfRoad + group.shoulder_left_m; + this.rightExtent = halfRoad + group.shoulder_right_m; + this.zCenter = params.designElevationM; + this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); + this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); + [this.leftRole, this.rightRole] = sideRole(params.sectionMode); + this.ditchSide = params.ditchSide; + this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); + this.twoStage = Boolean( + params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, + ); + this.groundAt = params.groundAt; + this.rockOffset = params.rockBoundaryOffsetM ?? 0; + this.ditchType = params.ditchType; + // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). + const slope = params.crossSlopePct / 100; + this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + + // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. + const groundAt = params.groundAt; + if (groundAt !== null) { + this.leftRole = + groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; + this.rightRole = + groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; + } + + // 측구 생성 여부(D-1). + if (params.sectionMode === "both_fill") { + this.hasDitch = false; + } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { + this.hasDitch = params.ditchEnabled; + } else if (groundAt !== null) { + const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; + } else { + this.hasDitch = true; + } + + // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + const outward = params.ditchSide === "left" ? 1 : -1; + const edgeZ = this.roadZ(edgeOffset); + if (this.hasDitch) { + if (params.ditchType === "l_type") { + // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. + const width = group.l_ditch_width_m; + const depth = group.l_ditch_depth_m; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * width, edgeZ - depth], + [edgeOffset + outward * width, edgeZ], + ]; + } else { + // 일반: 상단폭/저폭/깊이 사다리꼴. + const top = group.ditch_top_width_m; + const bottom = Math.min(group.ditch_bottom_width_m, top); + const depth = group.ditch_depth_m; + const inset = (top - bottom) / 2; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * inset, edgeZ - depth], + [edgeOffset + outward * (inset + bottom), edgeZ - depth], + [edgeOffset + outward * top, edgeZ], + ]; + } + } + } + + /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ + roadZ(offsetM: number): number { + return this.zCenter + this.slopePerOffset * offsetM; + } + + /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ + private slopeStart(side: string): [number, number] { + const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + if (side === this.ditchSide && this.ditchPoints.length) { + const outer = this.ditchPoints[this.ditchPoints.length - 1]; + return [Math.abs(outer[0]), outer[1]]; + } + return [edgeOffset, edgeZ]; + } + + /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ + private rockBoundaryZ(side: string, dist: number): number { + const signed = side === "left" ? dist : -dist; + return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; + } + + /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ + knee(side: string): [number, number] | null { + if (!this.twoStage) return null; + const cached = this.rockKnee.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + let diffPrev = startZ - this.rockBoundaryZ(side, startDist); + let result: [number, number] | null; + if (diffPrev >= 0) { + result = [startDist, startZ]; // 시작부터 토사(경계 위) + } else { + result = null; + let distPrev = startDist; + let dist = startDist + MARCH_STEP_M; + while (dist <= startDist + KNEE_MAX_M) { + const zRock = startZ + (dist - startDist) / this.cutRatio; + const diff = zRock - this.rockBoundaryZ(side, dist); + if (diff >= 0) { + const span = diff - diffPrev; + const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; + const kneeDist = distPrev + (dist - distPrev) * ratio; + const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; + result = [kneeDist, kneeZ]; + break; + } + distPrev = dist; + diffPrev = diff; + dist += MARCH_STEP_M; + } + } + this.rockKnee.set(side, result); + return result; + } + + /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + private cutSlopeZ(side: string, dist: number): number { + const [startDist, startZ] = this.slopeStart(side); + const knee = this.twoStage ? this.knee(side) : null; + if (knee !== null) { + const [kneeDist, kneeZ] = knee; + if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; + return kneeZ + (dist - kneeDist) / this.soilCutRatio; + } + return startZ + (dist - startDist) / this.cutRatio; + } + + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ + cutCrossDist(side: string): number | null { + const cached = this.cutCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.cutCross.set(side, result); + return result; + } + + /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ + fillCrossDist(side: string): number | null { + const cached = this.fillCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist, startZ] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + const fillLine = startZ - (dist - startDist) / this.fillRatio; + if (fillLine - this.groundAt(signed) <= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.fillCross.set(side, result); + return result; + } + + /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ + fillGroundSlope(): number | null { + if (this.groundAt === null) return null; + const slopes: number[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const [startDist] = this.slopeStart(side); + const endDist = this.fillCrossDist(side) ?? startDist + 10; + const run = endDist - startDist; + if (run <= 1e-6) continue; + const sign = side === "left" ? 1 : -1; + const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); + slopes.push(rise / run); + } + return slopes.length ? Math.min(...slopes) : null; + } + + /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ + designZ(offsetM: number, groundM: number): number { + const side = offsetM >= 0 ? "left" : "right"; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); + // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if (side === this.ditchSide && this.ditchPoints.length) { + const inner = Math.abs(this.ditchPoints[0][0]); + const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); + if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { + const points = this.ditchPoints; + for (let index = 1; index < points.length; index += 1) { + const x0 = Math.abs(points[index - 1][0]); + const z0 = points[index - 1][1]; + const x1 = Math.abs(points[index][0]); + const z1 = points[index][1]; + if (Math.abs(offsetM) > x1 + 1e-9) continue; + const span = x1 - x0; + if (span <= 1e-9) return z1; + return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); + } + return points[points.length - 1][1]; + } + } + const role = side === "left" ? this.leftRole : this.rightRole; + const [startDist, startZ] = this.slopeStart(side); + const dist = Math.abs(offsetM); + const run = dist - startDist; + if (role === "cut") { + const cross = this.cutCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.min(this.cutSlopeZ(side, dist), groundM); + } + const cross = this.fillCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.max(startZ - run / this.fillRatio, groundM); + } + + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ + breakpoints(): number[] { + const points = [0, this.leftExtent, -this.rightExtent]; + for (const [offset] of this.ditchPoints) points.push(offset); + if (this.twoStage) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const knee = role === "cut" ? this.knee(side) : null; + if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + } + } + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); + if (cross !== null) points.push(side === "left" ? cross : -cross); + } + return points; + } +} + +/** + * 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다. + * + * `samples` 는 지반선 원시 샘플, `designElevationM` 은 중심선 계획고(노면고)다. + * 계산 불가(계획고 없음·샘플 부족·잘못된 유형)면 던진다 — 호출부가 그 측점을 건너뛴다. + */ +export function computeCrossDesign( + samples: CrossGroundSample[], + designElevationM: number | null | undefined, + options: CrossDesignOptions, +): CrossDesignResult { + const groundType = options.groundType; + const sectionMode = options.sectionMode; + const ditchType = options.ditchType ?? "standard"; + const paved = Boolean(options.paved); + if (!(groundType in GROUND_TYPE_PRESET)) { + throw new Error(`지원하지 않는 지반유형입니다: ${groundType}`); + } + if (!SECTION_MODES.includes(sectionMode)) { + throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); + } + if (!DITCH_TYPES.includes(ditchType)) { + throw new Error(`지원하지 않는 측구 형식입니다: ${ditchType}`); + } + if (designElevationM === null || designElevationM === undefined) { + throw new Error("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다."); + } + const drop = Math.max(options.surfaceDropM ?? 0, 0); + const centerElevation = designElevationM - drop; + + const presetKey = GROUND_TYPE_PRESET[groundType]; + if (ditchType === "l_type" && presetKey !== "rock") { + throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); + } + const group = resolveGroup(presetKey, options.standard); + const pavedGroup = resolveGroup("paved", options.standard); + // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. + const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; + const resolvedDitchSide = resolveDitchSide(sectionMode, options.ditchSide); + + const valid: Array<[number, number]> = samples + .filter( + (sample) => + sample.valid !== false && + sample.offset_m !== null && + sample.offset_m !== undefined && + sample.elevation_m !== null && + sample.elevation_m !== undefined, + ) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]) + .sort((a, b) => a[0] - b[0]); + if (valid.length < 2) { + throw new Error("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다."); + } + + const groundAt = groundInterpolator(valid); + const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null; + // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. + const enableTwoStage = + presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; + const geometry = new SectionGeometry({ + designElevationM: centerElevation, + group, + sectionMode, + ditchSide: resolvedDitchSide, + ditchType, + crossSlopePct, + groundAt, + soilCutRatio: resolveGroup("soil", options.standard).cut_slope_ratio, + rockBoundaryOffsetM, + twoStageSlope: enableTwoStage, + ditchEnabled: options.ditchEnabled ?? null, + }); + + // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). + const minOffset = valid[0][0]; + const maxOffset = valid[valid.length - 1][0]; + const mergedSet = new Set(valid.map(([offset]) => round6(offset))); + for (const point of geometry.breakpoints()) { + if (point >= minOffset && point <= maxOffset) mergedSet.add(round6(point)); + } + const merged = [...mergedSet].sort((a, b) => a - b); + + const offsets: number[] = []; + const diffs: number[] = []; + const designLine: CrossDesignEdge[] = []; + for (const offsetM of merged) { + const groundM = groundAt(offsetM); + const designZ = geometry.designZ(offsetM, groundM); + offsets.push(offsetM); + diffs.push(groundM - designZ); + designLine.push({ offset_m: round4(offsetM), elevation_m: round4(designZ) }); + } + + // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). + const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); + const fillGroundSlope = geometry.fillGroundSlope(); + const slopeUnclosed = + diffs.length > 0 && + (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || + Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M); + + // 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암. + let cutSoilArea: number; + let cutRockArea: number; + let cutRockKind: string | null; + if (presetKey !== "rock") { + cutSoilArea = cutArea; + cutRockArea = 0; + cutRockKind = null; + } else if (rockBoundaryOffsetM === null) { + cutSoilArea = 0; + cutRockArea = cutArea; + cutRockKind = groundType; + } else { + [cutSoilArea, cutRockArea] = splitCutAreas(offsets, diffs, Math.abs(rockBoundaryOffsetM)); + cutRockKind = groundType; + } + + // 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사. + let ditchArea: number; + let ditchSpec: Record; + if (!geometry.hasDitch) { + ditchArea = 0; + ditchSpec = { type: "none" }; + } else if (ditchType === "l_type") { + ditchArea = (group.l_ditch_width_m * group.l_ditch_depth_m) / 2; + ditchSpec = { + type: "l_type", + width_m: group.l_ditch_width_m, + depth_m: group.l_ditch_depth_m, + }; + } else { + ditchArea = ((group.ditch_top_width_m + group.ditch_bottom_width_m) / 2) * group.ditch_depth_m; + ditchSpec = { + type: "standard", + top_width_m: group.ditch_top_width_m, + bottom_width_m: group.ditch_bottom_width_m, + depth_m: group.ditch_depth_m, + }; + } + + // 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2). + let resolvedMode: string; + if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut"; + else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill"; + else if (geometry.leftRole === "cut") resolvedMode = "left_cut"; + else resolvedMode = "right_cut"; + + const result: CrossDesignResult = { + ground_type: groundType, + geometry_preset: presetKey, + section_mode: resolvedMode, + ditch_side: resolvedDitchSide, + ditch_type: geometry.hasDitch ? ditchType : null, + cut_slope_ratio: round4(geometry.cutRatio), + soil_cut_slope_ratio: round4(geometry.soilCutRatio), + two_stage_slope: geometry.twoStage, + fill_slope_ratio: round4(geometry.fillRatio), + roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), + carriageway_width_m: round4(group.road_width_m), + cross_slope_pct: round4(crossSlopePct), + ditch: ditchSpec, + ditch_enabled: geometry.hasDitch, + paved, + road_edges: { + left: { + offset_m: round4(geometry.leftExtent), + elevation_m: round4(geometry.roadZ(geometry.leftExtent)), + }, + right: { + offset_m: round4(-geometry.rightExtent), + elevation_m: round4(geometry.roadZ(-geometry.rightExtent)), + }, + }, + carriageway_edges: { + left: { + offset_m: round4(geometry.halfRoad), + elevation_m: round4(geometry.roadZ(geometry.halfRoad)), + }, + right: { + offset_m: round4(-geometry.halfRoad), + elevation_m: round4(geometry.roadZ(-geometry.halfRoad)), + }, + }, + design_elevation_m: round4(centerElevation), + cut_area_m2: round4(cutArea), + cut_soil_area_m2: round4(cutSoilArea), + cut_rock_area_m2: round4(cutRockArea), + cut_rock_kind: cutRockKind, + fill_area_m2: round4(fillArea), + slope_unclosed: slopeUnclosed, + fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), + ditch_area_m2: round4(ditchArea), + design_line: designLine, + }; + if (drop > 0) result.surface_drop_m = round4(drop); + if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); + if (presetKey === "rock" && rockBoundaryOffsetM !== null) { + result.rock_boundary_offset_m = round4(rockBoundaryOffsetM); + } + return result; +} + +/** 짝: 파이썬 `round(offset, 6)` — 병합 격자 중복 제거 기준. */ +function round6(value: number): number { + return Math.round(value * 1e6) / 1e6; +} diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts new file mode 100644 index 00000000..081cea98 --- /dev/null +++ b/common_util/common_util_cross_design_areas.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * common_util_cross_design_areas.ts + * 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리. + * + * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠ + * 짝: `B06_Section/B06_Section_Engine_Areas.py` + * 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다: + * `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것. + * 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자 + * 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다. + * + * 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다. + * ========================================================================== */ + +/** + * 오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 [절토, 성토] 면적을 낸다. + * + * diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서 + * 나눠 절·성토가 섞이지 않게 한다. + */ +export function trapezoidAreas(offsets: number[], diffs: number[]): [number, number] { + let cutArea = 0; + let fillArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + if (d0 === 0 && d1 === 0) continue; + if (d0 * d1 < 0) { + // 부호 변화: 영교점에서 두 삼각형으로 분리 + const zeroRatio = d0 / (d0 - d1); + const xZero = x0 + width * zeroRatio; + const leftArea = 0.5 * (xZero - x0) * Math.abs(d0); + const rightArea = 0.5 * (x1 - xZero) * Math.abs(d1); + if (d0 > 0) { + cutArea += leftArea; + fillArea += rightArea; + } else { + fillArea += leftArea; + cutArea += rightArea; + } + continue; + } + const area = 0.5 * (d0 + d1) * width; + if (area >= 0) cutArea += area; + else fillArea += -area; + } + return [cutArea, fillArea]; +} + +/** + * 절토 면적을 암반 경계선 기준으로 [토사, 암반]으로 나눈다. + * + * 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께 + * `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에 + * 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상 + * `max(d, 0)`이라 `trapezoidAreas`의 절토 면적과 정확히 일치한다. + * + * 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야 + * 사다리꼴 적분이 근사가 아닌 정확값이 된다. + */ +export function splitCutAreas( + offsets: number[], + diffs: number[], + soilDepthM: number, +): [number, number] { + const t0 = Math.max(soilDepthM, 0); + let soilArea = 0; + let rockArea = 0; + for (let index = 1; index < offsets.length; index += 1) { + const x0 = offsets[index - 1]; + const x1 = offsets[index]; + const d0 = diffs[index - 1]; + const d1 = diffs[index]; + const width = x1 - x0; + if (width <= 0) continue; + const ratios = [0, 1]; + for (const level of [0, t0]) { + if ((d0 - level) * (d1 - level) < 0) ratios.push((level - d0) / (d1 - d0)); + } + ratios.sort((a, b) => a - b); + for (let step = 1; step < ratios.length; step += 1) { + const ratioA = ratios[step - 1]; + const ratioB = ratios[step]; + const span = width * (ratioB - ratioA); + if (span <= 0) continue; + const dA = d0 + (d1 - d0) * ratioA; + const dB = d0 + (d1 - d0) * ratioB; + soilArea += ((Math.min(Math.max(dA, 0), t0) + Math.min(Math.max(dB, 0), t0)) / 2) * span; + rockArea += ((Math.max(dA - t0, 0) + Math.max(dB - t0, 0)) / 2) * span; + } + } + return [soilArea, rockArea]; +} -- 2.54.0 From dcabce147e3a0c6d32f9b4e392ea5d120220441e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 19:54:13 +0900 Subject: [PATCH 042/441] =?UTF-8?q?feat(B04):=20=EC=A7=80=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=203D=20=EB=B0=A9=EC=9C=84=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=203=EC=B6=95=20=ED=8A=B8=EB=9D=BC=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 옛 콤파스는 방위각(`atan2(ox, oz)`)만 써서 경사에 따른 남북 압축이 빠졌음 — 사시도 프리셋(오프셋 120·95·135, 앙각 27.7°)에서 바늘 41.6°, 화면 실제 북쪽 62.4°로 20.8° 어긋났음(2026-09-03 사용자 지적). 세계 축 N(−z)·E(+x)·Z(+y)를 카메라 화면 기저(오른쪽 r, 위 u)에 투영해 선과 글자를 그림. 기저는 카메라 오프셋만으로 구함(up = +y 고정). 뒤로 누운 축(깊이 > 0.25)과 정면으로 와 짧아진 축은 흐리게 그려 겹침을 줄임. 검증: typecheck 통과. 기본 시점에서 DOM 좌표가 해석값과 일치 — N (28.0, 11.0) · E (45.0, 28.0) · Z (28.0, 27.4). 회전 중 거동은 사용자 화면 검증 대기. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_UI_Compass.ts | 99 ++++++++++++++----- B04_PreProcess/B04_PreProcess_UI_Style.css | 27 ++--- .../B04_PreProcess_UI_TerrainViewer.ts | 6 +- 3 files changed, 93 insertions(+), 39 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_UI_Compass.ts b/B04_PreProcess/B04_PreProcess_UI_Compass.ts index 92c98b5d..11f6c740 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Compass.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Compass.ts @@ -1,49 +1,96 @@ -/* B04 지표면 3D 뷰어 방위 콤파스 — 바늘이 늘 도북(N)을 가리킨다. +/* B04 지표면 3D 뷰어 방위 표시 — 카메라 자세를 그대로 따르는 **3축 트라이어드**. * - * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x, 높이, -y. 그래서 세계에서 북쪽은 - * **-z** 다. 화면 위쪽은 카메라가 보는 수평 방향이므로, 카메라 오프셋(카메라 − 타깃)만 - * 알면 북쪽이 화면에서 몇 도 돌아가 있는지 나온다 — `Math.atan2(offset.x, offset.z)`. - * (기본 시점 offset=(0, d, d·tilt)면 0° = 북쪽이 화면 위, 카메라가 동쪽으로 가면 +90°.) + * 종전에는 방위각만으로 도는 평면 콤파스였다. 3D 화면은 기울어 있어 남북이 화면에서 + * 눌리는데(사시도 앙각 27.7° → 0.47배) 바늘은 안 눌린 각도로 돌아, 실제 화면 북쪽과 + * 최대 20.8° 어긋났다(2026-09-03 사용자 지적·실측). 축을 카메라 기저에 투영해 그리면 + * 그 어긋남이 원리적으로 사라진다. + * + * 뷰어 좌표 규약은 백엔드 scene_vertices와 같다: x(동), 높이, -y. 그래서 세계에서 + * 북쪽은 **-z**, 동쪽은 **+x**, 표고는 **+y** 다. 카메라 오프셋(카메라 − 타깃)만 있으면 + * 화면 기저를 만들 수 있다(up = +y 고정): + * · 오른쪽 r = (oz, 0, −ox)/h, 위 u = (−ox·oy, h², −oz·oy)/(h·L) + * · 화면 좌표 = (v·r, v·u), 깊이 = v·(−offset)/L — 양수면 시선 방향(뒤)이라 흐리게. * * 뷰어 파일이 이미 900줄을 넘어 여기로 뺐다(CLAUDE.md 4장 700줄 제한). */ export interface TerrainCompass { root: HTMLElement; - /** 카메라 오프셋(카메라 위치 − 타깃)으로 바늘 각도를 맞춘다. */ - update(offsetX: number, offsetZ: number): void; + /** 카메라 오프셋(카메라 위치 − 타깃)으로 축 방향을 맞춘다. */ + update(offsetX: number, offsetY: number, offsetZ: number): void; setVisible(visible: boolean): void; } -const NEEDLE_SVG = ` -
+
-
diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 97b8a443..6f54fd60 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -224,9 +224,51 @@ export const ui_locales_b1 = { "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], + /* 긴 설명은 좌측 안내 패널(B03_Guide_*)로 옮겼다 — 선택 영역은 한 줄만 남긴다 + (2026-09-03 사용자 지시). */ B03_File_Select_Hint: [ - "계획노선(CSV 또는 shapefile 5개)과 LAS/LAZ 1개, 지형 PRJ·TFW를 함께 고르면 카드에 나뉩니다. TIF는 선택 사항입니다.", - "Pick the planned route (a CSV or the five shapefile files), one LAS/LAZ, and the terrain PRJ and TFW together — they are sorted into cards. TIF is optional.", + "끌어 놓거나 눌러서 고르면 확장자대로 카드에 나뉩니다.", + "Drop files here or click to pick — they are sorted into cards by extension.", + ], + /* --- 좌측 안내 패널 (2026-09-03) --- */ + B03_Guide_Files_Title: ["필요한 파일", "Files you need"], + B03_Guide_Files_Route: [ + "계획노선 — CSV 한 장, 또는 shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", + "Planned route — one CSV, or the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", + ], + B03_Guide_Files_Terrain: [ + "지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.", + "Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.", + ], + B03_Guide_Files_Optional: [ + "지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.", + "The terrain TIF (DEM) is optional — surface analysis runs without it.", + ], + B03_Guide_How_Title: ["고르는 방법", "How to pick"], + B03_Guide_How_Drop: [ + "여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.", + "Drop several files at once — each is assigned to a card by its extension.", + ], + B03_Guide_How_Card: [ + "카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.", + "Use a card's [Select] to set one slot, or ✕ to clear it.", + ], + B03_Guide_How_Temp: [ + "[임시 보관함에서 불러오기]는 대시보드에 미리 올려 둔 자료를 가져옵니다.", + "[Load from temporary storage] pulls files you staged on the dashboard.", + ], + B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"], + B03_Guide_Notes_Replace: [ + "이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.", + "Uploading again to a finished project replaces the existing analysis.", + ], + B03_Guide_Notes_Crs: [ + "작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.", + "The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.", + ], + B03_Guide_Notes_LasFree: [ + "라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.", + "With no LiDAR, turn on [Design without LAS] and work from sheet contours.", ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], -- 2.54.0 From b8aa5ce01471268d42fb9359ad78c260781b0fd5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:32:46 +0900 Subject: [PATCH 049/441] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=A0=88=C2=B7=EC=84=B1=ED=86=A0=20?= =?UTF-8?q?=EB=B6=88=EA=B7=A0=ED=98=95=20=ED=91=9C=EC=8B=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20+=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EA=B9=9C=EB=B9=A1=EC=9E=84=20=EC=A0=9C=EA=B1=B0=20+=20?= =?UTF-8?q?=EC=89=AC=ED=94=84=ED=8A=B8=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 3건. 1. 상단 표시줄의 「절·성토 불균형」은 **종단 기준** 값이었음 — 계획선과 지반선 사이 세로 면적(㎡)의 비라 실제 토량이 아니고 판단 근거가 안 됨. 삭제함. 의미 있는 균형은 횡단 단면적을 쌓아 부피(㎥)로 내는 하단 유토곡선 요약이고 그쪽은 그대로 둠. 2. 계획고를 연속으로 누르면 유토곡선이 매번 사라졌다 다시 서서 깜빡였음. 재계산 대기 중에 곡선을 **지우지 않고 직전 것을 그대로 두며**, 요약줄 끝에 「다시 계산 중…」 칩만 붙임. 재계산이 브라우저 안에서 수십 ms에 끝나므로 화면이 비는 구간이 없어짐. 아직 한 번도 못 그렸을 때만 예전처럼 문구로 자리를 채움. 3. [쉬프트]에서 아무것도 안 골라지던 것은 **직선화된 구간이 없어서**였음(초기 계획선은 마디마다 기울기가 달라 직선 구간이 0개). 안내를 갈라 적음 — 「직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요」 / 「…고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요」. 직선화 안내에도 취소 방법을 덧붙임. 전체 테스트 183건 통과, `tsc --noEmit` 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 14 +++++----- .../B05_Profile_UI_Profile_MassHaul.ts | 27 ++++++++++++++++--- .../B05_Profile_UI_Profile_Panel_Tools.ts | 5 ++++ .../B05_Profile_UI_Profile_Straighten.ts | 16 +++++++++++ B05_Profile/B05_Profile_UI_Profile_Tools.ts | 10 +++++-- B05_Profile/B05_Profile_UI_Style_MassHaul.css | 8 ++++++ 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 5f7f7c68..f6166abe 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -4,9 +4,9 @@ * * 표시 항목(별표2 근거): * 최대 기울기 x.x% (상한 y%) — Ⅰ.2.라: 설계속도·지형별 상한 준수 판정 - * 불균형 z% / 허용 w% — Ⅰ.1.나.(4)(다): 시공계획고 절·성토 균형 * 곡선 필요 n곳 — Ⅰ.2.마: 대수차 5% 초과 변화점의 종단곡선 삽입 - * 절·성토 면적은 하단 유토곡선이 부피로 더 정확히 보여 주고, 변화점·종단곡선 개수와 + * 절·성토 균형은 **하단 유토곡선**이 횡단 기준 부피(㎥)로 보여 준다 — 여기 있던 종단 기준 + * 불균형(㎡ 비율)은 실제 토량이 아니라 의미가 없어 뺐다(2026-09-03). 변화점·종단곡선 개수와 * 기본 곡선길이 L은 판단 기준이 없어 뺐다(2026-08-19 사용자 지적 + 지식DB 대조). * [저장선 복원]은 상단에서 내려 그래프 조작부로 옮겼다. * @@ -53,7 +53,7 @@ export function renderBalanceBar(params: BalanceBarParams): void { return; } const { alignment } = params; - const { balance, policy, violations } = alignment; + const { policy, violations } = alignment; // 최대 종단기울기 — 법정 상한 대비가 이 화면의 첫 판정 항목이다(별표2 Ⅰ.2.라). const steepest = alignment.segments.reduce( (worst, segment) => Math.max(worst, Math.abs(segment.grade_percent)), @@ -70,12 +70,10 @@ export function renderBalanceBar(params: BalanceBarParams): void { `${steepest.toFixed(1)} % / 상한 ${policy.max_grade_pct.toFixed(1)} %`, violations.length ? "over" : undefined, ], - [ - "절·성토 불균형", - `${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`, - balance.within_tolerance ? undefined : "over", - ], ]; + // 절·성토 불균형은 2026-09-03 사용자 지시로 뺐다. 이 값은 **종단 기준**이었다 — + // 계획선과 지반선 사이 세로 면적(㎡)의 절·성토 비이지 실제 토량이 아니다. 의미 있는 + // 균형은 횡단 단면적을 쌓아 부피로 내는 하단 유토곡선 요약(㎥)이고, 그쪽이 이미 있다. // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 840737d7..42c0cdfc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -293,7 +293,11 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa mirror(scroll, main); } + /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ + let drawnOnce = false; + function note(message: string): void { + drawnOnce = false; bar.replaceChildren(); scroll.replaceChildren(); legendLayer.replaceChildren(); @@ -312,11 +316,25 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); return; } - // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 **옛 계획고로 만든 값**이다. - // 그걸 잠깐 그렸다가 재계산 결과로 갈아 끼우면 사용자가 옛 그림을 본다(2026-09-03 - // 사용자 확정: 「새 값만 보여주기」). Panel이 이미 재계산을 예약해 두므로 기다린다. + // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 옛 계획고로 만든 값이다 — + // 새 값이 올 때까지 기다린다(2026-09-03 사용자 확정: 「새 값만 보여주기」). + // + // 다만 **곡선을 지우지는 않는다**. 계획고를 연속으로 누르면(초당 10회) 매번 곡선이 + // 사라졌다 다시 서서 화면이 깜빡였다(2026-09-03 사용자 보고). 재계산은 브라우저 + // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 + // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { - note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); + if (drawnOnce) { + const pending = bar.querySelector(".b05-profile__masshaul-pending"); + if (!pending) { + const chip = document.createElement("span"); + chip.className = "b05-profile__masshaul-pending"; + chip.textContent = "다시 계산 중…"; + bar.append(chip); + } + } else { + note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); + } return; } // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. @@ -386,6 +404,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa onChanged(); }), ); + drawnOnce = true; } return { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index a22a7aaa..06ce78de 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -19,6 +19,7 @@ import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Prof import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { detectStraightRun, + hasStraightRuns, shiftStraightRuns, straightenBetween, tiltStraightRun, @@ -118,6 +119,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { onRedo: () => inner.redo(), canUndo: () => inner.canUndo(), canRedo: () => inner.canRedo(), + hasStraightRuns: () => { + const alignment = ctx.alignment(); + return alignment ? hasStraightRuns(alignment) : false; + }, onChanged: ctx.refresh, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts index 1bcc587e..5b9279de 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Straighten.ts @@ -114,6 +114,22 @@ export function detectStraightRun( }; } +/** + * 지금 계획선에 **직선화된 구간이 하나라도 있는가** — [쉬프트] 안내 문구용. + * + * 초기 계획선은 모든 측점이 변화점이라 마디마다 기울기가 달라 직선 구간이 없다. 그때 + * [쉬프트]로 아무 데나 눌러도 잡히는 것이 없어 「선택이 안 된다」로 보였다 + * (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다. + */ +export function hasStraightRuns(alignment: ProfileAlignment): boolean { + for (let index = 1; index < alignment.segments.length; index += 1) { + if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) { + return true; + } + } + return false; +} + /** 두 측점 사이(끝 제외)의 변화점 chainage. */ function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] { return pviChainages(alignment).filter( diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index e42c66a6..758e2e55 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -28,6 +28,8 @@ export interface ProfileToolsCallbacks { onRedo: () => void; canUndo: () => boolean; canRedo: () => boolean; + /** 지금 계획선에 직선화된 구간이 있는가 — [쉬프트] 안내 문구를 가른다. */ + hasStraightRuns: () => boolean; /** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */ onChanged: () => void; } @@ -166,12 +168,16 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; - hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요`; + hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`; wrap.append(hint); } else if (mode === "shift" && !runs.length) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; - hint.textContent = "직선화된 라인을 고르세요"; + // 고를 것이 아예 없는 경우와 있는데 안 고른 경우를 갈라 적는다 — 초기 계획선은 + // 마디마다 기울기가 달라 직선 구간이 없어, 눌러도 안 잡히는 것이 정상이다. + hint.textContent = callbacks.hasStraightRuns() + ? "직선화된 라인을 고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요" + : "직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요"; wrap.append(hint); } return wrap; diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 5ee9930a..c482c4ca 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -112,6 +112,14 @@ padding-top: 0; } +/* 재계산 대기 표시 — 곡선을 지우지 않고 요약줄 끝에만 붙인다(2026-09-03 깜빡임 제거). */ +.b05-profile__masshaul-pending { + margin-left: auto; + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.8; +} + .b05-profile__masshaul-empty { padding: var(--spacing-8) var(--spacing-16); color: var(--color-text-secondary); -- 2.54.0 From c2b3c566640219328e3edb902ab415b76105dbe0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:35:29 +0900 Subject: [PATCH 050/441] =?UTF-8?q?fix(B03):=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=ED=8C=A8=EB=94=A9=20=EB=B3=B5=EA=B5=AC=20=E2=80=94?= =?UTF-8?q?=20=ED=85=8C=EB=A7=88=EC=97=90=20=EC=97=86=EB=8A=94=20`--spacin?= =?UTF-8?q?g-20`=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `padding: var(--spacing-20)` 은 정의되지 않은 토큰이라 선언째 무효가 되어 카드 컨테이너의 패딩이 통째로 사라졌음(2026-09-03 사용자 보고). 16px 토큰으로 교체. 검증: 실측 계산값 — 컨테이너 16px, 카드 12/16px, 선택 바 12/16px, 안내 문단 12px. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Style.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index e77e94c2..d53a46a4 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -21,8 +21,9 @@ flex-direction: column; gap: var(--spacing-16); background: var(--color-canvas, #ffffff); - /* 여백 32 → 20 (2026-09-03) — 고르는 자리만 남기고 안내를 좌측 패널로 뺐다. */ - padding: var(--spacing-20); + /* 여백 32 → 16 (2026-09-03) — 고르는 자리만 남기고 안내를 좌측 패널로 뺐다. + `--spacing-20`은 테마에 없는 토큰이라 선언째 무효가 된다(패딩이 통째로 사라짐). */ + padding: var(--spacing-16); border-radius: var(--radius-large, 24px); /* Wiza 24px 대형 둥글기 */ border: 1px solid var(--color-mist, #e6e2e3); box-shadow: var(--shadow-lg); -- 2.54.0 From 68e0a1c67f225b58142d5e817bfd0d6ba1a7a5a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:39:52 +0900 Subject: [PATCH 051/441] =?UTF-8?q?feat(B03):=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=A6=AC=20=ED=95=9C=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C=20=EC=95=88=EB=82=B4=EB=8A=94=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시 3건(2026-09-03). - 재접속 현황 배너(「…모두 완료된 프로젝트입니다」)를 좌측 안내 패널 「고르는 방법」 안으로 옮김 — 고르기 전에 읽을 것과 같은 자리. - [입력 파일 선택]·[임시 보관함에서 불러오기]·[파일 업로드]를 한 줄(`b03-file__pick-row`)로 묶음. 좁아지면 접히되 선택 영역이 남는 폭을 먹음. - 업로드 완료 항목 목록 삭제 — 같은 내용을 카드가 완료 상태로 이미 보여 줌. 쓰이지 않게 된 `renderUploadResults`와 `.b03-file__results` 규칙도 제거. 곁들여 고친 것 — 카드 격자를 2열 고정에서 `auto-fill minmax(260px, 1fr)` 로 바꿈. 안내 패널을 연 상태에서 카드 폭이 210px 로 눌려 제목이 글자 단위로 접혔음(실측). 제목은 한 줄 말줄임으로 둠. 검증: 한 줄 배치 실측 — 선택 397px · 보관함 331px · 업로드 102px, 높이 76px 로 정렬. 카드 66px(빈 칸)/117px(파일 있음), 제목 줄 높이 21px(한 줄). typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Guide.ts | 22 ++++-- B03_FileInput/B03_FileInput_UI_Page.ts | 43 +++++------ B03_FileInput/B03_FileInput_UI_Style.css | 94 +++++++----------------- B03_FileInput/B03_FileInput_UI_Upload.ts | 16 ---- 4 files changed, 58 insertions(+), 117 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts index fce2ccd7..dfa389d3 100644 --- a/B03_FileInput/B03_FileInput_UI_Guide.ts +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -40,21 +40,29 @@ function section( return group; } -/** 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것. */ -export function createInputGuide(): HTMLElement { +/** + * 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것. + * + * `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라 + * 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다 + * (2026-09-03 사용자 지시). + */ +export function createInputGuide(statusNote?: HTMLElement): HTMLElement { const guide = document.createElement("div"); guide.className = "b03-file__guide"; + const howTo = section("B03_Guide_How_Title", [ + "B03_Guide_How_Drop", + "B03_Guide_How_Card", + "B03_Guide_How_Temp", + ]); + if (statusNote) howTo.append(statusNote); guide.append( section("B03_Guide_Files_Title", [ "B03_Guide_Files_Route", "B03_Guide_Files_Terrain", "B03_Guide_Files_Optional", ]), - section("B03_Guide_How_Title", [ - "B03_Guide_How_Drop", - "B03_Guide_How_Card", - "B03_Guide_How_Temp", - ]), + howTo, section("B03_Guide_Notes_Title", [ "B03_Guide_Notes_Replace", "B03_Guide_Notes_Crs", diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 1cb44dd8..300589f4 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -10,7 +10,7 @@ import { createButton, createTag, showToast } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; -import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch"; +import { fetchUploadOverview } from "./B03_FileInput_Api_Fetch"; import { clearPreloadMark } from "../A00_Common/b_asset_cache"; import { navigateTo } from "../A00_Common/router"; import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; @@ -29,7 +29,6 @@ import { confirmReplaceUpload, isInitialPipelineRunning, pollInitialPipeline, - renderUploadResults, uploadOneFile, } from "./B03_FileInput_UI_Upload"; import { @@ -63,8 +62,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다. const tempPicker = createTempPicker(() => updateUploadButton()); const cardMap = new Map(); - const resultList = document.createElement("ul"); - resultList.className = "b03-file__results"; let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; @@ -653,22 +650,20 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; setUploading(true); - const uploaded: UploadedFileResult[] = []; try { for (let index = 0; index < targetStates.length; index += 1) { const state = targetStates[index]; - uploaded.push( - ...(await uploadOneFile( - activeProjectId, - state, - index === targetStates.length - 1, - () => renderSlot(state.slot), - lasFreeDesign, - )), + await uploadOneFile( + activeProjectId, + state, + index === targetStates.length - 1, + () => renderSlot(state.slot), + lasFreeDesign, ); } clearDerivedCaches(activeProjectId); - renderUploadResults(resultList, uploaded); + // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 + // 않는다(2026-09-03 사용자 지시). showToast(L("B03_File_Upload_Success"), "success"); showToast(L("B03_File_Analysis_InProgress"), "info"); @@ -737,19 +732,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise { disabled: true, }); + // 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함에서 불러오기] · [파일 업로드] + // (2026-09-03 사용자 지시). 셋이 한 동작의 앞뒤라 흩어 놓을 이유가 없다. + const pickRow = document.createElement("div"); + pickRow.className = "b03-file__pick-row"; + pickRow.append(dropzone, tempPicker.root, uploadButton); + const uploadControlPanel = document.createElement("div"); uploadControlPanel.className = "b03-file__control-panel"; - uploadControlPanel.append( - subtitle, - overviewBanner, - dropzone, - // 대시보드 임시 보관함에서 자료를 끌어오는 자리 — 업로드 컨테이너 안에 둔다. - tempPicker.root, - resumeBanner, - pageError, - uploadButton, - resultList, - ); + uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError); // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 @@ -848,7 +839,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const overlays = createWorkflowOverlays({ title: L("B03_File_Title"), progressContent, - optionsContent: createInputGuide(), + optionsContent: createInputGuide(overviewBanner), // 공용 워크플로 레이아웃(B04·B05)이 하는 일을 여기서도 한다 — 패널이 열리면 본문을 // 그만큼 밀어내지 않으면 안내가 카드 위를 덮는다. onOptionsOpenChange: (isOpen) => layout.root.classList.toggle("is-options-open", isOpen), diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index d53a46a4..e654bc45 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -45,6 +45,25 @@ } /* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */ +/* 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함] · [파일 업로드](2026-09-03). + 좁아지면 아래로 접히되, 선택 영역이 남는 폭을 먹는다. */ +.b03-file__pick-row { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: var(--spacing-8); +} + +.b03-file__pick-row .b03-file__dropzone { + flex: 1 1 320px; +} + +.b03-file__pick-row .b03-file__temp-picker, +.b03-file__pick-row .ui-btn { + flex: 0 0 auto; + align-self: stretch; +} + /* 한 줄 바(2026-09-03) — 종전 160px 블록은 안내 문구를 담느라 컸다. 문구는 좌측 패널로 갔으므로 여기는 「끌어 놓거나 눌러서 고르는 자리」 표시만 한다. */ .b03-file__dropzone { @@ -266,9 +285,11 @@ margin: 0; } +/* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다 + (2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */ .b03-file__group-content { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--spacing-12); } @@ -371,11 +392,10 @@ color: var(--color-deep-iris, #26114a); font-size: var(--text-body-sm, 14px); font-weight: var(--font-weight-medium, 500); - /* 한글은 글자 단위로 끊기므로 어절을 지켜 준다 — "계획 -노선 -도형" 방지. */ - word-break: keep-all; - overflow-wrap: break-word; + /* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .b03-file__card-ext, @@ -525,65 +545,3 @@ .b03-file__card--error .b03-file__progress-section { display: none; } - -.b03-file__results { - margin: var(--spacing-8) 0 0 0; - padding: 0; - list-style: none; - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - overflow: hidden; - background: var(--color-canvas, #ffffff); -} - -.b03-file__results:empty { - display: none; -} - -.b03-file__results li { - padding: var(--spacing-12) var(--spacing-16); - display: flex; - flex-direction: column; - gap: 2px; - border-bottom: 1px solid var(--color-mist, #e6e2e3); - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); -} - -.b03-file__results li:last-child { - border-bottom: 0; -} - -@media (max-width: 1440px) { - /* 좁아지면 두 컨테이너를 위아래로 쌓는다 — 카드가 눌려 글자가 접히는 것을 막는다. */ - .b03-file__columns { - grid-template-columns: 1fr; - } -} - -@media (max-width: 720px) { - .b03-file { - padding: var(--spacing-16) var(--spacing-12); - } - - .b03-file__group-content { - grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */ - } -} - -/* LAS 없는 설계(도엽등고선 기반) 토글 — 2026-08-30 */ -.b03-file__lasfree { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 2px; - font-size: var(--text-body); - color: var(--color-text-body); - cursor: pointer; - user-select: none; -} - -.b03-file__card--disabled { - opacity: 0.45; - pointer-events: none; -} diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index dafe030b..3f69e449 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -73,22 +73,6 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi } /** 업로드 결과 목록(파일명 + 저장 경로)을 다시 그린다. */ -export function renderUploadResults( - list: HTMLElement, - results: readonly UploadedFileResult[], -): void { - list.replaceChildren(); - for (const result of results) { - const item = document.createElement("li"); - const filename = document.createElement("strong"); - filename.textContent = result.original_filename; - const path = document.createElement("span"); - path.textContent = `${L("B03_File_Result_Path")}: ${result.relative_path}`; - item.append(filename, path); - list.append(item); - } -} - /** * 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다. * 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다. -- 2.54.0 From 66717162c5e436fd5068e732aafd5f3a77a5cc8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:42:23 +0900 Subject: [PATCH 052/441] =?UTF-8?q?feat(B05):=20=EB=8F=84=EA=B5=AC?= =?UTF-8?q?=EC=A4=84=20[=EC=B4=88=EA=B8=B0=ED=99=94]=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20+=20=EC=89=AC=ED=94=84=ED=8A=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EB=B9=A8=EA=B0=84=20=EA=B0=95=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [초기화] — [↶] 왼쪽에 둠. 전체 초기화가 아니라 **마지막 [저장] 시점**이 기준점임 (사용자 지시). 편집 스토어가 저장 시점 편집분(`savedBaseline`)을 들고 있다가 그리로 되돌림 — 저장한 작업은 남고 그 뒤 편집만 버림. 이력에 기록해 [↶]로 되살릴 수 있음. 되돌릴 것이 없으면 버튼은 비활성. 자동 선형까지 지우는 `resetAll`(좌측 [초기화])과는 다른 조작임. 2. [쉬프트]로 고른 직선이 화면에 표시되지 않아 무엇이 잡혔는지 알 수 없었음. 고른 구간을 빨갛게 덧그림 — 범위는 **직선 + 양 끝 라운드(R)** 임. 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 모양까지 함께 바뀜. 구간 시작 라운드의 BVC부터 끝 라운드의 EVC 까지 계획선 샘플을 그대로 덧그려 곡률이 따라옴. 경계에는 점선 세로 표식. 공용 종단 렌더러(`B06_Section_UI_Longitudinal`)는 건드리지 않고 별도 오버레이로 얹음 (`B05_Profile_UI_Profile_RunHighlight.ts`). Y 매핑은 그 렌더러가 넘겨 주는 축 눈금에서 되짚어 같은 스케일을 씀. 편집 버튼층(z-index 4) 아래(3)라 버튼을 가리지 않음. `tsc --noEmit` 통과, 전체 테스트 183건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 21 +++- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 3 + .../B05_Profile_UI_Profile_Panel_Tools.ts | 10 ++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 16 +++ .../B05_Profile_UI_Profile_RunHighlight.ts | 111 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Profile_Tools.ts | 16 ++- B05_Profile/B05_Profile_UI_Style.css | 26 ++++ 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 2679735b..dd5325b8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -63,8 +63,13 @@ function createHoldRepeater(): { start: (action: () => void) => void; stop: () = export interface ProfileEditStore { edits(): AlignmentEdits; replace(next: AlignmentEdits): void; - /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */ + /** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. + * 이때의 값이 [초기화]의 기준점이 된다. */ markSaved(): void; + /** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */ + restoreSaved(): void; + /** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */ + canRestoreSaved(): boolean; resetStation(chainageM: number): void; resetAll(): void; /** 아직 서버에 반영되지 않은 변경이 있는가. */ @@ -114,6 +119,12 @@ export function createProfileEditStore( const draft = readDraft(storageKey); let current = draft ?? saved; let unsaved = draft !== null; + /** + * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). + * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 + * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. + */ + let savedBaseline = saved; function dropDraft(): void { try { @@ -139,8 +150,16 @@ export function createProfileEditStore( replace: commit, markSaved() { unsaved = false; + savedBaseline = current; dropDraft(); }, + restoreSaved() { + current = savedBaseline; + unsaved = false; + dropDraft(); + onChange(); + }, + canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline), resetStation(chainageM) { const key = chainageKey(chainageM); const stationOffsets = { ...current.station_offsets }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 5e25ee90..37e81e3d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -389,6 +389,8 @@ export function createRouteProfilePanel( store = createProfileEditStore(routeId, savedEdits, () => rebuild()); rebuild(); }, + restoreSaved: () => store.restoreSaved(), + canRestoreSaved: () => store.canRestoreSaved(), refresh: () => renderBalance(), }); @@ -505,6 +507,7 @@ export function createRouteProfilePanel( applyEdits, handleToolPick, toolActive: () => tools.mode() !== "none", + selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, redraw: draw, }); diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 06ce78de..f886aa09 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -54,6 +54,10 @@ export interface PanelToolsContext { }; /** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */ restore: () => void; + /** 마지막 [저장] 시점으로 편집을 되돌린다(스토어 소관). */ + restoreSaved: () => void; + /** 되돌릴 것이 남아 있는가 — [초기화] 버튼 활성 조건. */ + canRestoreSaved: () => boolean; /** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */ refresh: () => void; } @@ -115,6 +119,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (!base) return; ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta)); }, + onResetToSaved: () => { + // 이력에 남겨 [↶]로 되살릴 수 있게 한다 — 잘못 눌러도 잃는 것이 없다. + ctx.restoreSaved(); + history.record(); + }, + canResetToSaved: ctx.canRestoreSaved, onUndo: () => inner.undo(), onRedo: () => inner.redo(), canUndo: () => inner.canUndo(), diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 13a0992d..10e6018d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -23,6 +23,8 @@ import { type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; +import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; @@ -83,6 +85,8 @@ export interface ProfileRenderContext { handleToolPick: (chainageM: number | null) => boolean; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; + /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ + selectedRuns: () => StraightRun[]; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; @@ -286,6 +290,18 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); + // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. + if (alignment) { + const highlight = createRunHighlight({ + alignment, + runs: ctx.selectedRuns(), + x, + axis: yAxis, + widthPx: width, + heightPx: chartHeight, + }); + if (highlight) chartWrap.append(highlight); + } if (alignment) { chartWrap.append( createEditOverlay({ diff --git a/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts new file mode 100644 index 00000000..c8db8b66 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_RunHighlight.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_RunHighlight.ts + * [쉬프트]로 고른 직선 구간 강조 오버레이 (2026-09-03 사용자 지시). + * + * 고른 직선을 눌러도 화면에 아무 표시가 없어 무엇이 잡혔는지 알 수 없었다. 강조 범위는 + * **직선 + 양 끝 라운드(R)** 다 — 쉬프트는 직선을 통째로 올리고 내리므로 양 끝 호의 + * 모양까지 함께 바뀌기 때문이다. 그래서 구간 시작 라운드의 BVC부터 끝 라운드의 EVC까지 + * 계획선 위를 그대로 덧그린다. + * + * 종단 그래프 SVG(`B06_Section_UI_Longitudinal`)는 B05·B06 공용이라 건드리지 않고, + * 같은 좌표계 위에 별도 오버레이를 얹는다. Y 매핑은 그 렌더러가 넘겨 준 축 눈금 + * (`onYAxis`)에서 되짚는다 — 두 눈금의 (표고, y) 두 쌍이면 1차식이 정해진다. + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 두 chainage를 같은 변화점으로 볼 허용 오차(m). */ +const SAME_NODE_M = 1e-6; + +export interface RunHighlightOptions { + alignment: ProfileAlignment; + /** 강조할 구간들(쉬프트 선택분). 비어 있으면 오버레이를 만들지 않는다. */ + runs: StraightRun[]; + /** 누가거리 → 화면 x(px). 종단 그래프와 같은 매핑이어야 선이 겹친다. */ + x: (chainageM: number) => number; + /** 종단 렌더러가 넘겨 준 Y축 눈금 — 표고 → y(px) 를 되짚는 근거. */ + axis: { ticks: Array<{ y: number; label: string }> } | null; + widthPx: number; + heightPx: number; +} + +/** 눈금 라벨(`880m`)에서 표고를 읽는다. 숫자가 아니면 null. */ +function tickElevation(label: string): number | null { + const value = Number.parseFloat(label); + return Number.isFinite(value) ? value : null; +} + +/** + * 축 눈금 두 개로 표고 → y(px) 1차식을 만든다. 눈금이 모자라거나 겹치면 null. + * (렌더러와 같은 스케일을 쓰려는 것이므로 별도로 계산하지 않는다.) + */ +function elevationToY( + axis: { ticks: Array<{ y: number; label: string }> } | null, +): ((elevationM: number) => number) | null { + const points = (axis?.ticks ?? []) + .map((tick) => ({ y: tick.y, elevation: tickElevation(tick.label) })) + .filter((entry): entry is { y: number; elevation: number } => entry.elevation !== null); + if (points.length < 2) return null; + const first = points[0]; + const last = points[points.length - 1]; + const span = last.elevation - first.elevation; + if (Math.abs(span) < 1e-9) return null; + const scale = (last.y - first.y) / span; + return (elevationM: number): number => first.y + (elevationM - first.elevation) * scale; +} + +/** 구간 양 끝 라운드까지 넓힌 강조 범위 [시작, 끝] (누가거리 m). */ +function runSpanWithCurves(alignment: ProfileAlignment, run: StraightRun): [number, number] { + const startCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.fromM) <= SAME_NODE_M && !curve.omitted, + ); + const endCurve = alignment.curves.find( + (curve) => Math.abs(curve.chainage_m - run.toM) <= SAME_NODE_M && !curve.omitted, + ); + return [startCurve ? startCurve.bvc_m : run.fromM, endCurve ? endCurve.evc_m : run.toM]; +} + +/** + * 고른 구간을 계획선 위에 빨갛게 덧그린 오버레이. 고른 것이 없으면 null. + * 반환한 요소는 종단 차트 래퍼(`b05-profile__chart`) 안에 그대로 붙이면 된다. + */ +export function createRunHighlight(options: RunHighlightOptions): SVGElement | null { + const { alignment, runs, x, widthPx, heightPx } = options; + if (!runs.length) return null; + const toY = elevationToY(options.axis); + if (!toY) return null; + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "b05-profile-runmark"); + svg.setAttribute("width", String(widthPx)); + svg.setAttribute("height", String(heightPx)); + svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`); + + const samples = alignment.samples; + for (const run of runs) { + const [fromM, toM] = runSpanWithCurves(alignment, run); + // 계획선 샘플에는 변화점·BVC·EVC가 모두 들어 있어 라운드 곡률까지 그대로 따라온다. + const points = samples + .filter((sample) => sample.chainage_m >= fromM - SAME_NODE_M) + .filter((sample) => sample.chainage_m <= toM + SAME_NODE_M) + .map((sample) => `${x(sample.chainage_m).toFixed(2)},${toY(sample.elevation_m).toFixed(2)}`); + if (points.length < 2) continue; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("class", "b05-profile-runmark__line"); + line.setAttribute("points", points.join(" ")); + svg.append(line); + // 양 끝 표시 — 어디까지가 이 구간인지(라운드 포함) 한눈에 보이게 세로 표식을 둔다. + for (const edge of [fromM, toM]) { + const tick = document.createElementNS(SVG_NS, "line"); + tick.setAttribute("class", "b05-profile-runmark__edge"); + tick.setAttribute("x1", x(edge).toFixed(2)); + tick.setAttribute("x2", x(edge).toFixed(2)); + tick.setAttribute("y1", "0"); + tick.setAttribute("y2", String(heightPx)); + svg.append(tick); + } + } + return svg.childElementCount ? svg : null; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 758e2e55..47e1e13f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile_UI_Profile_Tools.ts - * 종단 계획선 도구줄 — [↶][↷] · [직선화] · [쉬프트] · [▲][▼] (2026-09-02 사용자 지시). + * 종단 계획선 도구줄 — [초기화] · [↶][↷] · [직선화] · [쉬프트] · [▲][▼]. + * ([초기화]는 2026-09-03 추가 — 마지막 [저장] 시점 기준이며 전체 초기화가 아니다.) * * 자리는 절·성토 요약줄의 **맨 앞**(최대 기울기 칩 왼쪽)이고, 버튼 크기는 그래프 위 * 틸팅 버튼과 같다(`b05-profile-edit__btn` 계열 크기를 CSS에서 공유). @@ -24,6 +25,9 @@ export interface ProfileToolsCallbacks { onShift: (runs: StraightRun[], delta: number) => void; /** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */ onTilt: (run: StraightRun, delta: number) => void; + /** 마지막 [저장] 시점으로 되돌린다 — 전체 초기화가 아니다. */ + onResetToSaved: () => void; + canResetToSaved: () => boolean; onUndo: () => void; onRedo: () => void; canUndo: () => boolean; @@ -140,6 +144,14 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo const wrap = document.createElement("span"); wrap.className = "b05-route-profile__tools"; + // [초기화] — 되돌리기 왼쪽. 기준은 **마지막 [저장] 시점**이고 저장한 작업은 남는다. + const reset = toolButton( + "초기화", + "마지막 [저장] 시점으로 되돌립니다 (저장한 작업은 그대로 남습니다)", + callbacks.onResetToSaved, + ); + reset.disabled = !callbacks.canResetToSaved(); + const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo); undo.disabled = !callbacks.canUndo(); const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo); @@ -164,7 +176,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; - wrap.append(undo, redo, straighten, shift, up, down); + wrap.append(reset, undo, redo, straighten, shift, up, down); if (mode === "straighten" && pending !== null) { const hint = document.createElement("em"); hint.className = "b05-route-profile__tool-hint"; diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 7233f768..af9ef682 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -935,6 +935,32 @@ } /* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ +/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 + (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ +.b05-profile-runmark { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.b05-profile-runmark__line { + fill: none; + stroke: var(--color-danger, #d64545); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ +.b05-profile-runmark__edge { + stroke: var(--color-danger, #d64545); + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.5; +} + .b05-profile-edit { position: absolute; z-index: 4; -- 2.54.0 From d9fcfa3b96564c439dc80c3f5613d6a50d1fb536 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:42:50 +0900 Subject: [PATCH 053/441] =?UTF-8?q?refactor(B03):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=ED=8C=A8=EB=84=90=EC=9D=84=20B04~B07=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=EC=96=91=EC=8B=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 처음엔 B03 전용 스타일로 문단을 만들었으나, 좌측 패널 양식은 다른 단계와 같아야 한다는 사용자 지시(2026-09-03). 공용 조립으로 교체함. - 패널 루트 `b03-file__form`, 문단은 `ui-collapsible ui-sidebar-section`, 제목은 `ui-collapsible__title` — 외곽선·캐럿·접힘 동작이 전부 공용 CSS·동작이다. - 값도 `b04-surface__form`·`__group`·`__group-legend` 와 같게 맞춤 (gap·padding 16, 배경 `--color-surface-raised`, legend `--text-caption`). 검증: 실측 — 문단 클래스 `b03-file__guide-group ui-collapsible ui-sidebar-section`, 패딩 16px, 배경 `rgb(37,31,56)`, 제목 클릭 시 `is-collapsed` 토글·복귀 확인. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Guide.ts | 14 ++++++++----- B03_FileInput/B03_FileInput_UI_Style.css | 25 +++++++++++++++--------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts index dfa389d3..f0e40830 100644 --- a/B03_FileInput/B03_FileInput_UI_Guide.ts +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -7,25 +7,28 @@ * 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로 * 옮기고, 본문은 고르는 자리만 남긴다. * - * 문단 구획은 공용 `ui-sidebar-section`을 쓴다(B04·B05 좌측 패널과 같은 테두리·간격). + * 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시): + * 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에 + * `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다. * ========================================================================== */ +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 안내 문단 하나 — 제목 + 항목 목록. */ +/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */ function section( titleKey: keyof typeof ui_locales, itemKeys: (keyof typeof ui_locales)[], ): HTMLElement { const group = document.createElement("section"); - group.className = "ui-sidebar-section b03-file__guide-group"; + group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section"; const title = document.createElement("h3"); - title.className = "b03-file__guide-title"; + title.className = "b03-file__guide-legend ui-collapsible__title"; title.textContent = L(titleKey); const list = document.createElement("ul"); @@ -49,7 +52,7 @@ function section( */ export function createInputGuide(statusNote?: HTMLElement): HTMLElement { const guide = document.createElement("div"); - guide.className = "b03-file__guide"; + guide.className = "b03-file__form"; const howTo = section("B03_Guide_How_Title", [ "B03_Guide_How_Drop", "B03_Guide_How_Card", @@ -69,5 +72,6 @@ export function createInputGuide(statusNote?: HTMLElement): HTMLElement { "B03_Guide_Notes_LasFree", ]), ); + attachCollapsible(guide); return guide; } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index e654bc45..ce3677b2 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -232,26 +232,33 @@ } } -/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리. --- */ -.b03-file__guide { +/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리. + 양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은 + `ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은 + `__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */ +.b03-file__form { display: flex; flex-direction: column; - gap: var(--spacing-12); - padding: var(--spacing-12); + gap: var(--spacing-16); + padding: var(--spacing-16); } .b03-file__guide-group { display: flex; flex-direction: column; gap: var(--spacing-8); - padding: var(--spacing-12); + margin: 0; + padding: var(--spacing-16); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); } -.b03-file__guide-title { +.b03-file__guide-legend { margin: 0; - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); - color: var(--color-text, #26114a); + padding: 0 var(--spacing-8); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); } .b03-file__guide-list { -- 2.54.0 From a00c7497c15afe1efbda78a1c2e46da0424408d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:47:02 +0900 Subject: [PATCH 054/441] =?UTF-8?q?feat(B03):=20=EB=B3=B4=EA=B4=80?= =?UTF-8?q?=ED=95=A8=20=EC=84=A0=ED=83=9D=20=EC=9A=94=EC=95=BD=C2=B7?= =?UTF-8?q?=E3=80=8C=EC=97=85=EB=A1=9C=EB=93=9C=ED=95=A0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=84=A0=ED=83=9D=ED=95=98=EC=84=B8?= =?UTF-8?q?=EC=9A=94=E3=80=8D=20=EA=B2=BD=EA=B3=A0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시 2건(2026-09-03). - 임시 보관함 선택 요약(「선택된 보관 자료 없음」·묶음 이름·파일 수)을 없앰. 불러온 자료는 카드가 곧바로 보여 주고, 고르기 전 상태는 [파일 업로드]가 잠긴 것으로 이미 드러남. 쓰이지 않게 된 `.b03-file__temp-summary` 규칙과 로케일 2건도 함께 제거. - 입력 컨테이너의 「업로드할 파일을 선택하세요」 경고를 띄우지 않음 — 아직 아무것도 고르지 않은 상태를 붉은 글씨로 알릴 이유가 없음. 확장자·개수·필수 슬롯 같은 나머지 사유와 [파일 업로드]를 눌렀을 때의 검사 결과는 그대로 표시. 검증: 실측 — 보관함 자리에 버튼 하나만 남음(요약 span 없음), 오류 줄 빈 문자열, 한 줄 배치 선택 518px · 보관함 210px · 업로드 102px. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 5 ++++- B03_FileInput/B03_FileInput_UI_Style_Temp.css | 11 ----------- B03_FileInput/B03_FileInput_UI_TempPicker.ts | 12 ++++-------- ui_template/ui_template_locale_b1.ts | 2 -- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 300589f4..3a95f7be 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -182,7 +182,10 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } const validation = validateSlots(); uploadButton.disabled = validation !== null; - pageError.textContent = validation ?? ""; + // 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에 + // 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다. + pageError.textContent = + validation === null || validation === L("B03_File_Error_Required") ? "" : validation; } /** 확장자 줄 — 지금 필수인지에 따라 "· 선택" 꼬리표가 붙고 떨어진다. */ diff --git a/B03_FileInput/B03_FileInput_UI_Style_Temp.css b/B03_FileInput/B03_FileInput_UI_Style_Temp.css index 4dfc2cde..345b0ebf 100644 --- a/B03_FileInput/B03_FileInput_UI_Style_Temp.css +++ b/B03_FileInput/B03_FileInput_UI_Style_Temp.css @@ -14,17 +14,6 @@ border-radius: var(--radius-cards); } -.b03-file__temp-summary { - font-family: var(--font-body); - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - -.b03-file__temp-summary.is-active { - color: var(--color-success); - font-weight: 700; -} - .b03-file__temp-modal { min-width: min(560px, 92vw); max-height: 80vh; diff --git a/B03_FileInput/B03_FileInput_UI_TempPicker.ts b/B03_FileInput/B03_FileInput_UI_TempPicker.ts index a94754dd..52d84a3c 100644 --- a/B03_FileInput/B03_FileInput_UI_TempPicker.ts +++ b/B03_FileInput/B03_FileInput_UI_TempPicker.ts @@ -46,19 +46,15 @@ export function createTempPicker( variant: "ghost", onClick: () => void openModal(), }); - const summary = document.createElement("span"); - summary.className = "b03-file__temp-summary"; - summary.textContent = L("B03_Temp_None"); - root.append(openButton, summary); + // 선택 요약(「선택된 보관 자료 없음」·묶음 이름)은 두지 않는다 — 불러온 자료는 카드가 + // 곧바로 보여 주고, 고르기 전 상태는 [파일 업로드]가 잠긴 것으로 이미 드러난다 + // (2026-09-03 사용자 지시). + root.append(openButton); let selectedBatch: TempBatchItem | null = null; function applySelection(batch: TempBatchItem | null): void { selectedBatch = batch; - summary.textContent = batch - ? `${L("B03_Temp_Selected")} ${batch.name} (${batch.files.length}${L("B03_Temp_FileCount")})` - : L("B03_Temp_None"); - summary.classList.toggle("is-active", Boolean(batch)); onSelected(batch); } diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 6f54fd60..317ad7e4 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -338,8 +338,6 @@ export const ui_locales_b1 = { B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], - B03_Temp_None: ["선택된 보관 자료 없음", "No stored set selected"], - B03_Temp_Selected: ["선택됨:", "Selected:"], B03_Temp_FileCount: ["개 파일", " files"], B03_Temp_Modal_Title: ["보관 자료 선택", "Select stored files"], B03_Temp_Modal_Empty: [ -- 2.54.0 From bc7b9e3a48f024ce333b213b90099b3a796560b2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:49:25 +0900 Subject: [PATCH 055/441] =?UTF-8?q?fix(B06):=20=EC=9C=A0=ED=86=A0=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=98=81=EC=97=AD=20=EB=88=84=EB=9D=BD=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=84=EC=82=B0=20=EA=B2=B0=EA=B3=BC=EC=99=80=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=A0=20=EC=A0=95=EB=B3=B8=EC=9D=B4=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=98=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 곳이 같은 뿌리였음. 브라우저 재계산은 **편집이 반영된 계획고**로 횡단 설계를 만드는데 `design_profiles` 는 저장분 그대로라 두 값의 기준이 갈렸음. 1. 재계산 창구가 방금 푼 계획선을 **공유 캐시에도 얹도록** 함 (`detail.longitudinal.design_profiles`). 이게 없으면 낡음 판정이 영원히 참이 되어 B06 유토곡선이 통째로 빈 채로 남았음(사용자 보고). `profile_alignment`(base_pvi)는 건드리지 않음 — 편집 델타의 기준선이라 편집분을 구워 넣으면 이중 적용됨. 2. B06 진입 정합이 **미저장 세션 편집을 못 보고 건너뛰던** 것. 서버에서 갓 받은 저장분 끼리는 늘 일치해 「낡지 않음」이 되는데, B05가 세션에 남긴 편집은 그 안에 없음. 세션 초안이 있으면 판정을 건너뛰고 무조건 맞추도록 함 — 재계산은 브라우저 안에서 끝나 값이 쌈. 화면 검증(5174, 같은 시점 비교): 수정 전 — B05 절토 4,774.1㎥ / 성토 16,715.5㎥, B06 절토 4,515.0㎥ / 성토 14,857.5㎥ 수정 후 — 양쪽 모두 절토 4,774.1㎥ / 성토 16,690.7㎥ (일치) B06 유토곡선 영역도 곡선이 다시 그려짐(문구 없음). 전체 테스트 183건 통과, `tsc --noEmit` 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Cross_Refresh.ts | 11 ++++++++++- B06_Section/B06_Section_UI_Page.ts | 10 ++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index f6ebbf33..28dee239 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -39,7 +39,7 @@ import { planElevationAt, toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; -import { readAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -118,6 +118,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { station_offsets: edits.station_offsets ?? {}, curve_radii: edits.curve_radii ?? {}, }); + // 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된 + // 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음 + // 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중 + // 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다** + // — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다. + detail.longitudinal.design_profiles = [ + toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), + ]; + const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {}; const rockDefault = readRockBoundaryDefault(projectId); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 72cf6adf..7cd9fa6a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -289,9 +289,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { */ async function reconcileStaleDesigns(): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; + const draft = readAlignmentDraft(currentRouteId); // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + // 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다. - if (!hasStaleDesigns(sectionDetail)) return; + // + // 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은 + // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 + // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 + // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. + if (!draft && !hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -303,7 +309,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }; } | undefined; - const edits = readAlignmentDraft(currentRouteId) ?? { + const edits = draft ?? { station_offsets: alignment?.edits?.station_offsets ?? {}, curve_radii: alignment?.edits?.curve_radii ?? {}, }; -- 2.54.0 From b9e6a2b6a8ecc030702c13a6ad6423f653dfe492 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 20:55:29 +0900 Subject: [PATCH 056/441] =?UTF-8?q?feat(B03):=20LAS=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=20=ED=86=A0=EA=B8=80=EC=9D=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=BC=9C=EB=A9=B4=20=EC=A7=80=ED=98=95=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=20=EC=A0=84=EC=B2=B4=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-03). - 토글을 지형 컨테이너에서 **파일 입력 컨테이너**로 옮김 — 무엇을 받을지 정하는 스위치라 고르는 자리 옆에 선다. 서식도 입혔음(공용 체크 줄 + 켜짐 강조). - 켜면 지형 자료를 **통째로** 받지 않음: 포인트클라우드·좌표계·래스터 카드를 모두 잠그고 컨테이너를 흐림. 필수 판정에서도 지형 슬롯 전체가 빠진다. - 파일 거르기를 확장자가 아니라 **배정된 카드** 기준으로 바꿈 — `.prj` 는 노선·지형이 같은 확장자라 확장자로 거르면 노선 좌표계까지 함께 떨어졌다. - 이미 올라간 자료(재입력)는 지우지 않고 **조작만 잠금** — 로컬로 고른 파일만 내린다. 좌측 안내 패널의 글자 크기도 공용 양식에 맞춤 — 본문 `--text-body-sm`·`--color-text-body` (B04 `b04-surface__check` 와 같음), 제목 `--text-caption`. 검증: 실측 — 토글이 입력 컨테이너 안에 있고, 켜면 지형 카드 4장이 모두 disabled + [파일 선택] 잠김, 기존 업로드분은 「완료」·파일명 유지. 끄면 원복. 패널 글자 14px/12px. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 65 ++++++++++++++---------- B03_FileInput/B03_FileInput_UI_Style.css | 56 ++++++++++++++++++-- 2 files changed, 90 insertions(+), 31 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 3a95f7be..e3a39138 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -303,28 +303,31 @@ export async function renderB03FileInput(root: HTMLElement): Promise { function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void { if (selection.length === 0) return; - // LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) — - // 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다. - const pointCloudExtensions = slots.get("las_laz")?.extensions ?? []; - const files = lasFreeDesign - ? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name))) - : selection; - const blocked = files.length !== selection.length; - if (blocked && files.length === 0) { - pageError.textContent = L("B03_File_Error_LasFreeBlocked"); - return; - } // 개수는 "고른 파일 수"가 아니라 **최종적으로 차는 슬롯 수**로 센다. // 같은 슬롯을 다시 고르는 것은 교체라 개수가 늘지 않는다 — 더하기로 세면 5개를 고른 // 뒤 파일 선택 영역으로 하나만 바꾸려 해도 초과로 막힌다(2026-08-08). // 파일 하나에 카드 하나다. `.prj`만 확장자로 안 갈리므로 노선 도형과 basename이 // 같은지로 노선/지형 좌표계 카드를 정한다(2026-08-31 사용자 지시). const routeFile = slots.get("csv")?.file?.name; - const assignments = planSlotAssignments( - files, + const planned = planSlotAssignments( + selection, slotConfigs(), routeFile ? routeFile.replace(/\.[^.]*$/, "") : undefined, ); + // LAS 없이 설계를 켜면 **지형 자료를 통째로** 받지 않는다(2026-09-03 사용자 지시 — + // 종전에는 포인트클라우드만 걸렀다). 확장자가 아니라 **배정된 카드**로 거른다: `.prj`는 + // 노선·지형이 같은 확장자라 확장자로 거르면 노선 좌표계까지 함께 떨어진다. + const assignments = lasFreeDesign + ? planned.filter((item) => { + const slot = targetSlot ?? item.slot; + return slot === undefined || !TERRAIN_SLOTS.includes(slot); + }) + : planned; + const blocked = assignments.length !== planned.length; + if (assignments.length === 0) { + pageError.textContent = blocked ? L("B03_File_Error_LasFreeBlocked") : ""; + return; + } const occupied = new Set(selectedStates().map((state) => state.slot)); for (const item of assignments) { const slot = targetSlot ?? item.slot; @@ -380,7 +383,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { */ function isSlotRequired(state: FileSlotState): boolean { if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(); - if (state.slot === "las_laz") return !lasFreeDesign; + // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. + if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; return state.isRequired; } @@ -772,18 +776,22 @@ export async function renderB03FileInput(root: HTMLElement): Promise { lasFreeRow.append(lasFreeCheck, lasFreeText); function applyLasFreeState(): void { lasFreeCheck.checked = lasFreeDesign; - const card = cardMap.get("las_laz"); - card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); - // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. - card - ?.querySelectorAll( - ".b03-file__card-select, .b03-file__slot-input", - ) - .forEach((element) => { - element.disabled = lasFreeDesign; - }); - // 켜기 전에 이미 골라 둔 LAS는 내린다 — 켠 채로 남아 올라가는 사고를 막는다. - if (lasFreeDesign && slots.get("las_laz")?.file) removeFile("las_laz"); + terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign); + for (const slot of TERRAIN_SLOTS) { + const card = cardMap.get(slot); + card?.classList.toggle("b03-file__card--disabled", lasFreeDesign); + // 카드를 회색으로 덮는 것만으로는 선택이 막히지 않는다 — 버튼·input을 실제로 잠근다. + card + ?.querySelectorAll( + ".b03-file__card-select, .b03-file__card-remove, .b03-file__slot-input", + ) + .forEach((element) => { + element.disabled = lasFreeDesign; + }); + // 켜기 전에 골라 둔 로컬 파일은 내린다 — 켠 채로 남아 올라가는 사고를 막는다. + // 이미 서버에 올라간 자료(재입력)는 그대로 두고 조작만 잠근다(2026-09-03 사용자 지시). + if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot); + } } lasFreeCheck.addEventListener("change", () => { lasFreeDesign = lasFreeCheck.checked; @@ -798,8 +806,9 @@ export async function renderB03FileInput(root: HTMLElement): Promise { updateUploadButton(); }); - // LAS 토글은 지형 컨테이너의 것이다 — 켜면 그 안의 포인트클라우드 카드만 잠긴다. - terrainGroup.append(lasFreeRow); + // LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지 + // 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다. + uploadControlPanel.insertBefore(lasFreeRow, resumeBanner); const routePanel = document.createElement("div"); routePanel.className = "b03-file__control-panel b03-file__cards-container-panel"; diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index ce3677b2..24ba87d1 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -212,6 +212,55 @@ align-items: start; } +/* LAS 없이 설계 토글 — 파일 입력 컨테이너의 스위치(2026-09-03 사용자 지시). + B04 좌측 패널의 체크 줄(`b04-surface__check`)과 같은 서식이다. */ +.b03-file__lasfree { + display: flex; + align-items: center; + gap: var(--spacing-8); + align-self: flex-start; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-mist, #e6e2e3); + border-radius: var(--radius-buttons, 8px); + font-size: var(--text-body-sm, 14px); + color: var(--color-deep-iris, #26114a); + cursor: pointer; + transition: border-color var(--transition-base, 0.2s); +} + +.b03-file__lasfree:hover { + border-color: var(--color-royal-amethyst, #3e0079); +} + +.b03-file__lasfree input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--color-royal-amethyst, #3e0079); + cursor: pointer; +} + +/* 켜져 있으면 스위치 자신도 켜진 티를 낸다 — 지형 컨테이너가 왜 잠겼는지의 근거. */ +.b03-file__lasfree:has(input:checked) { + border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-mist-violet, #edecff); + font-weight: var(--font-weight-medium, 500); +} + +/* LAS 없이 설계를 켜면 지형 컨테이너는 받지 않는다 — 잠긴 것이 보이게 흐린다. */ +.b03-file__group--disabled { + opacity: 0.55; +} + +.b03-file__card--disabled { + background: var(--color-paper, #f6f7fa); + border-style: dashed; +} + +.b03-file__card--disabled .b03-file__card-select { + cursor: not-allowed; +} + /* 좌측 안내 패널이 열리면 본문을 그만큼 밀어낸다 — 공용 워크플로 레이아웃(B04·B05)의 `.ui-workflow-layout__body` 규칙과 같은 값. 접히면 손잡이 폭만 남긴다. */ .b03-file__main-layout { @@ -261,15 +310,16 @@ color: var(--color-text-secondary); } +/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은 + `--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */ .b03-file__guide-list { margin: 0; padding-left: var(--spacing-16); display: flex; flex-direction: column; gap: var(--spacing-4); - font-size: var(--text-caption, 12px); - line-height: 1.55; - color: var(--color-text-secondary, #615e6e); + font-size: var(--text-body-sm); + color: var(--color-text-body); } .b03-file__group { -- 2.54.0 From 061265f6057301c758ad62d69f7a9e0a5a624b77 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 21:01:56 +0900 Subject: [PATCH 057/441] =?UTF-8?q?fix(B06):=20=EC=95=94=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=84=B8=EC=85=98=20=EC=98=A4=ED=94=84=EC=85=8B?= =?UTF-8?q?=EC=9D=84=20=EC=9E=90=EB=A6=BF=EC=88=98=20=EB=95=8C=EB=AC=B8?= =?UTF-8?q?=EC=97=90=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EB=86=93=EC=B9=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션에 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰는데 브라우저 재계산은 `toFixed(3)` 문자열로 찾아 **한 건도 못 읽었음**. 서버 경로는 키를 숫자로 바꿔 비교해 문제가 없었으므로 로컬 계산으로 옮기며 생긴 어긋남임. 키를 숫자로 되돌려 0.01m 단위로 맞춤. B06 에서 암 경계선을 올리내린 값이 이제 B05 유토곡선·횡단 면적에도 그대로 반영됨. 화면 검증(5173→5174, 같은 시점): B05 첫 진입 절토 4,774.1㎥ / 성토 16,690.7㎥, B06 도 같은 값(재계산 입력·결과 로그로 대조 — 측점 65, 샘플 65, 자연방토 25건까지 동일). B06 을 거치기 전과 후의 값 차이(성토 16,715.5 → 16,690.7)도 사라져 **첫 진입부터 일치**함. 진단용 임시 로그는 제거함. 전체 테스트 183건 통과, `tsc --noEmit` 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Cross_Refresh.ts | 28 ++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 28dee239..8f91e139 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -99,6 +99,30 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + const raw = readRockBoundarySession(projectId, routeId) ?? {}; + const offsets = new Map(); + for (const [key, value] of Object.entries(raw)) { + const chainage = Number(key); + if (Number.isFinite(chainage) && typeof value === "number" && Number.isFinite(value)) { + offsets.set(rockKey(chainage), value); + } + } + return offsets; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -127,7 +151,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]), ]; - const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {}; + const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); const updated: number[] = []; @@ -137,7 +161,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const design = previous as unknown as Record; // 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버 // `recompute_designs_for_alignment` 의 우선순위와 같다. - const sessionOffset = rockOffsets[section.chainage_m.toFixed(3)]; + const sessionOffset = rockOffsets.get(rockKey(section.chainage_m)); const storedOffset = design.rock_boundary_offset_m; const rockBoundaryOffsetM = typeof sessionOffset === "number" -- 2.54.0 From 7c3a65e814e9d96d04d7121a2ff3c534fadf9b14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 21:02:19 +0900 Subject: [PATCH 058/441] =?UTF-8?q?feat(B03):=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=20=EC=84=A4=EB=AA=85=20=EC=9D=B4=EA=B4=80=20?= =?UTF-8?q?=C2=B7=20=EC=9E=84=EC=8B=9C=20=EB=B3=B4=EA=B4=80=ED=95=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=C2=B7=ED=85=8C=EB=91=90=EB=A6=AC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EA=B3=84=ED=9A=8D=EB=85=B8=EC=84=A0?= =?UTF-8?q?=EC=9D=80=20shapefile=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시 4건(2026-09-03). - 선택 영역의 설명 문구를 좌측 안내 패널로 옮기고 본문에는 「입력 파일 선택」만 남김. - [임시 보관함에서 불러오기] → [임시 보관함]. 버튼을 감싸던 점선 테두리 삭제 — 버튼 자신의 테두리와 이중이었음. - 계획노선 도형 카드는 `.shp` 만 받음. CSV 는 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아님(사용자 확인). 컨테이너의 CSV 안내 문구도 삭제. - 이미 `.csv` 로 올라간 옛 프로젝트가 「미등록」으로 보이지 않도록 **서버 파일 매핑에만** `.csv → 계획노선 도형` 예외를 둠. 새로 받는 확장자와 이미 받은 것을 갈라 둔다. 좌측 안내 문구도 함께 갱신 — CSV 언급 제거, 버튼 새 이름 반영. 검증: 실측 — 선택 영역 텍스트 「입력 파일 선택」, 보관함 테두리 none·라벨 「임시 보관함」, 계획노선 안내 문구 없음, 카드 확장자 `.shp`, 옛 CSV 는 「완료 · 용화_계획노선.csv」 유지. typecheck·prettier 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 20 +++++++++++-------- B03_FileInput/B03_FileInput_UI_Style_Temp.css | 7 ++----- B03_FileInput/B03_FileInput_UI_Support.ts | 4 +++- ui_template/ui_template_locale_b1.ts | 20 +++++-------------- 4 files changed, 22 insertions(+), 29 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index e3a39138..4a74dc82 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -95,9 +95,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { dropzone.tabIndex = 0; const dropzoneLabel = document.createElement("strong"); dropzoneLabel.textContent = L("B03_File_Select_Label"); - const dropzoneHint = document.createElement("span"); - dropzoneHint.textContent = L("B03_File_Select_Hint"); - dropzone.append(dropzoneLabel, dropzoneHint, fileInput); + // 설명은 좌측 안내 패널 몫이다(2026-09-03 사용자 지시) — 여기는 자리 이름만 남긴다. + dropzone.append(dropzoneLabel, fileInput); const pageError = document.createElement("p"); pageError.className = "b03-file__error"; @@ -435,10 +434,14 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? inRouteSet ? "route_prj" : "prj" - : Array.from(slots.values()).find( - (candidate) => - candidate.slot !== "route_prj" && candidate.extensions.includes(extension), - )?.slot; + : // 옛 프로젝트의 노선은 `.csv`로 올라가 있다 — 이제 받지는 않지만(2026-09-03) + // 이미 올라간 것은 계획노선 도형 카드에 그대로 보여야 한다. + extension === ".csv" + ? "csv" + : Array.from(slots.values()).find( + (candidate) => + candidate.slot !== "route_prj" && candidate.extensions.includes(extension), + )?.slot; const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { @@ -752,11 +755,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). // 좌표계 파일(.prj)도 각각 하나씩 오므로 컨테이너를 나눠야 어느 칸에 무엇을 넣는지 // 화면만 보고 안다(2026-08-31 사용자 지시). + // 안내 문구 없음 — CSV 관련 설명이 붙어 있었으나 CSV는 사용자가 넣는 자료가 아니다 + // (2026-09-03 사용자 지시: 내부 계산 파일). const routeGroup = createCardGroup( L("B03_File_Group_Route"), ROUTE_SLOTS, "b03-file__group--route", - L("B03_File_Group_Route_Hint"), ); const terrainGroup = createCardGroup( L("B03_File_Group_Terrain"), diff --git a/B03_FileInput/B03_FileInput_UI_Style_Temp.css b/B03_FileInput/B03_FileInput_UI_Style_Temp.css index 345b0ebf..11edd0e0 100644 --- a/B03_FileInput/B03_FileInput_UI_Style_Temp.css +++ b/B03_FileInput/B03_FileInput_UI_Style_Temp.css @@ -4,14 +4,11 @@ * 모달 껍데기(.b03-file__modal / -backdrop)는 기존 확인 모달 스타일을 그대로 쓴다. * ========================================================================== */ +/* 버튼 하나만 담는 자리 — 테두리를 또 두르면 버튼 테두리와 이중이 된다 + (2026-09-03 사용자 지시). */ .b03-file__temp-picker { display: flex; align-items: center; - gap: var(--spacing-12); - flex-wrap: wrap; - padding: var(--spacing-12); - border: 1px dashed var(--color-border); - border-radius: var(--radius-cards); } .b03-file__temp-modal { diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 93f17894..7b4e1707 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -60,7 +60,9 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ slot: "csv", labelKey: "B03_File_Slot_PlannedRoute", icon: "⌁", - extensions: [".csv", ".shp"], + // `.csv`는 받지 않는다 — 내부 계산이 만드는 파일이라 사용자가 넣는 자료가 아니다 + // (2026-09-03 사용자 지시). 슬롯 키 `csv`는 저장·서버 규약이라 그대로 둔다. + extensions: [".shp"], isRequired: true, }, { diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 317ad7e4..cbcaa673 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -224,17 +224,11 @@ export const ui_locales_b1 = { "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], - /* 긴 설명은 좌측 안내 패널(B03_Guide_*)로 옮겼다 — 선택 영역은 한 줄만 남긴다 - (2026-09-03 사용자 지시). */ - B03_File_Select_Hint: [ - "끌어 놓거나 눌러서 고르면 확장자대로 카드에 나뉩니다.", - "Drop files here or click to pick — they are sorted into cards by extension.", - ], /* --- 좌측 안내 패널 (2026-09-03) --- */ B03_Guide_Files_Title: ["필요한 파일", "Files you need"], B03_Guide_Files_Route: [ - "계획노선 — CSV 한 장, 또는 shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", - "Planned route — one CSV, or the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", + "계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", + "Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", ], B03_Guide_Files_Terrain: [ "지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.", @@ -254,8 +248,8 @@ export const ui_locales_b1 = { "Use a card's [Select] to set one slot, or ✕ to clear it.", ], B03_Guide_How_Temp: [ - "[임시 보관함에서 불러오기]는 대시보드에 미리 올려 둔 자료를 가져옵니다.", - "[Load from temporary storage] pulls files you staged on the dashboard.", + "[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.", + "[Temporary storage] pulls files you staged on the dashboard.", ], B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"], B03_Guide_Notes_Replace: [ @@ -310,10 +304,6 @@ export const ui_locales_b1 = { B03_File_Group_Inputs: ["입력 자료", "Input files"], B03_File_Group_Route: ["계획노선 자료", "Planned route files"], B03_File_Group_Terrain: ["지형 자료 (LAS)", "Terrain files (LAS)"], - B03_File_Group_Route_Hint: [ - "shapefile은 파일 5개가 한 벌입니다. CSV 한 장으로 넣어도 됩니다.", - "A shapefile is a set of five files. A single CSV also works.", - ], B03_File_Group_Terrain_Hint: [ "여기의 PRJ·TFW는 지형 자료의 좌표계입니다 — 노선 PRJ와 별개입니다.", "The PRJ and TFW here describe the terrain data, separate from the route PRJ.", @@ -337,7 +327,7 @@ export const ui_locales_b1 = { B03_File_Slot_TerrainDem: ["지형 래스터", "Terrain DEM"], B03_File_Slot_CadDrawing: ["CAD 도면", "CAD Drawing"], /* --- B03 임시 보관함 불러오기 (2026-08-08) --- */ - B03_Temp_Btn_Open: ["임시 보관함에서 불러오기", "Load from temporary storage"], + B03_Temp_Btn_Open: ["임시 보관함", "Temporary storage"], B03_Temp_FileCount: ["개 파일", " files"], B03_Temp_Modal_Title: ["보관 자료 선택", "Select stored files"], B03_Temp_Modal_Empty: [ -- 2.54.0 From db91109b5a70d10e25db238140c0e43f01270408 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 3 Sep 2026 21:06:48 +0900 Subject: [PATCH 059/441] =?UTF-8?q?fix(B03):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=203=EC=B9=B8=201:1:1=C2=B7=EB=86=92=EC=9D=B4?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC,=20=ED=86=A0=EA=B8=80=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=EB=B9=88=20=EC=9E=90=EB=A6=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-03). - 고르는 자리를 flex 에서 **격자 3칸**으로 바꿈. flex 로는 내용 폭이 배분에 섞여 288·254·288 로 어긋났고 버튼 높이도 달랐다. 격자는 칸을 먼저 나누므로 1:1:1 이고, 칸을 꽉 채우게 해 세 칸 높이가 같아진다(실측 277×47 셋 다 동일). - 빈 오류 줄이 자리를 잡아 토글 아래가 비어 보이던 것 제거 — 글자가 있을 때만 자리를 차지한다(`:empty { display: none }`, min-height·마진 제거). Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Style.css | 29 ++++++++++++++++-------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 24ba87d1..ac1f2dcc 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -47,21 +47,25 @@ /* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */ /* 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함] · [파일 업로드](2026-09-03). 좁아지면 아래로 접히되, 선택 영역이 남는 폭을 먹는다. */ +/* 셋을 같은 폭·같은 높이로 나눈다 — 열 1:1:1(2026-09-03 사용자 지시). + flex 로는 버튼·드롭존의 내용 폭이 배분에 섞여 288·254·288 로 어긋났다. 격자는 + 칸을 먼저 나누므로 내용과 무관하게 정확히 1:1:1 이 된다. 좁아지면 칸이 접힌다. */ .b03-file__pick-row { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); align-items: stretch; gap: var(--spacing-8); } -.b03-file__pick-row .b03-file__dropzone { - flex: 1 1 320px; +.b03-file__pick-row > * { + min-width: 0; } -.b03-file__pick-row .b03-file__temp-picker, -.b03-file__pick-row .ui-btn { - flex: 0 0 auto; - align-self: stretch; +/* 칸을 꽉 채워야 세 칸의 높이가 같아진다 — 버튼은 기본이 내용 크기다. */ +.b03-file__pick-row .b03-file__temp-picker > .ui-btn, +.b03-file__pick-row > .ui-btn { + width: 100%; + height: 100%; } /* 한 줄 바(2026-09-03) — 종전 160px 블록은 안내 문구를 담느라 컸다. 문구는 좌측 @@ -109,11 +113,16 @@ color: var(--color-slate, #615e6e); } +/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 + 자리를 차지한다(2026-09-03 사용자 지시). */ .b03-file__error { - min-height: var(--spacing-16); color: var(--color-danger, #dc2626); font-size: var(--text-body-sm, 14px); - margin: var(--spacing-8) 0; + margin: 0; +} + +.b03-file__error:empty { + display: none; } .b03-file__resume { -- 2.54.0 From af85dea7148b521c4f20217ddf6ac21dda9ac9af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 06:37:57 +0900 Subject: [PATCH 060/441] =?UTF-8?q?feat(ui=5Ftemplate):=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8B=A8=EA=B3=84=20=ED=8C=A8=EB=84=90=20=EB=81=8C?= =?UTF-8?q?=EC=96=B4=20=EC=98=AE=EA=B8=B0=EA=B8=B0=C2=B7=EC=97=B4=EB=A6=BC?= =?UTF-8?q?=20=EB=B0=A9=ED=96=A5=20=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더를 잡아 끌어 자리 이동, 이동 범위는 상단 헤더 아래 화면 안으로 clamp - 아래 공간 부족 시 `is-flip-up` 으로 헤더 위로 펼침(바닥 기준 정렬) - 자리는 sessionStorage 세션 캐시에만 보관, 접힘/펴짐 뒤 방향 재판정 - threshold 4px 초과 시에만 포인터 캡처 — pointerdown 캡처는 click 대상을 제목에서 헤더로 바꿔 접기/펴기 토글을 죽임(실측) - 이동은 window 에서 청취 — B07 CAD iframe 위로 커서가 나가도 안 끊김 검증: 공용 브라우저 B03·B05·B07 실측(이동 오차 1px 이내, clamp·뒤집힘·토글 정상), tsc --noEmit 통과 Co-Authored-By: Claude Opus 5 (1M context) --- ui_template/ui_template_overlay.css | 15 +++ ui_template/ui_template_overlay.ts | 10 ++ ui_template/ui_template_overlay_drag.ts | 169 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui_template/ui_template_overlay_drag.ts diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index b139123a..e4372eb4 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -59,6 +59,21 @@ width: calc(var(--wf-left-panel-width) / 2); } +/* 끌어 옮기기 (2026-09-04) — 헤더를 잡아 끈다. 자리를 옮기면 인라인 left/top이 + 위 고정값을 덮어쓴다. 아래 공간이 모자라면 `is-flip-up`으로 헤더 위로 펼친다. */ +.ui-workflow-overlay__panel--progress .ui-workflow-overlay__header { + cursor: move; + touch-action: none; +} + +.ui-workflow-overlay__panel--progress.is-flip-up { + flex-direction: column-reverse; +} + +.ui-workflow-overlay__panel--progress.is-dragging { + user-select: none; +} + .ui-workflow-overlay__header { display: flex; align-items: center; diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 3deb4200..d2736098 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,8 +1,10 @@ import "./ui_template_overlay.css"; import { t } from "./ui_template_locale"; +import { makePanelDraggable } from "./ui_template_overlay_drag"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; +const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos"; export interface WorkflowOverlayOptions { title: string; @@ -146,6 +148,8 @@ function createPanel( root.append(body); } + let dragHandle: { refresh: () => void } | null = null; + function setOpen(isOpen: boolean): void { root.classList.toggle("is-collapsed", !isOpen); if (panelHandle) panelHandle.setOpen(isOpen); @@ -156,6 +160,8 @@ function createPanel( toggle.setAttribute("aria-expanded", String(isOpen)); } sessionStorage.setItem(storageKey, String(isOpen)); + // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. + if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); } @@ -174,6 +180,10 @@ function createPanel( }); toggle.addEventListener("click", toggleOpen); setOpen(readOpenState(storageKey)); + // 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바). + if (variant === "progress") { + dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY); + } return { root, setOpen }; } diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts new file mode 100644 index 00000000..adb456bd --- /dev/null +++ b/ui_template/ui_template_overlay_drag.ts @@ -0,0 +1,169 @@ +/** + * 진행단계 패널 끌어 옮기기 (2026-09-04 사용자 지시). + * + * 패널은 `position: fixed`라 좌표계가 곧 뷰포트다. 이동 범위는 **상단 헤더 아래 + * 화면 안**으로 잡아, 패널을 화면 밖으로 흘려 다시 못 잡는 상태를 원천 차단한다. + * + * 헤더에는 이미 제목 클릭 = 접기/펴기 토글이 붙어 있으므로(`ui_template_overlay.ts`) + * 이동 threshold 4px를 두고, 넘으면 드래그로 보고 뒤따르는 click 한 번을 삼킨다. + * 안 그러면 옮길 때마다 패널이 접힌다. + * + * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). + */ + +const DRAG_THRESHOLD_PX = 4; +/** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ +const MIN_TOP_PX = 64; +/** 화면 가장자리에서 남겨 둘 여백. */ +const EDGE_GAP_PX = 8; + +export interface PanelDragHandle { + /** 펼침/접힘이 바뀐 뒤 열리는 방향을 다시 판정한다. */ + refresh: () => void; +} + +interface PanelPosition { + left: number; + top: number; +} + +function readPosition(key: string): PanelPosition | null { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed?.left !== "number" || typeof parsed?.top !== "number") return null; + return { left: parsed.left, top: parsed.top }; + } catch { + return null; + } +} + +export function makePanelDraggable( + root: HTMLElement, + header: HTMLElement, + storageKey: string, +): PanelDragHandle { + let position: PanelPosition | null = readPosition(storageKey); + let pointerId: number | null = null; + let startX = 0; + let startY = 0; + let baseLeft = 0; + let baseTop = 0; + let moved = false; + + function bodyHeight(): number { + if (root.classList.contains("is-collapsed")) return 0; + const body = root.querySelector(".ui-workflow-overlay__body"); + return body ? body.offsetHeight : 0; + } + + /** 헤더 왼쪽 위 좌표를 화면 안으로 가둔다. */ + function clamp(left: number, top: number): PanelPosition { + const width = root.offsetWidth; + const headerHeight = header.offsetHeight; + const maxLeft = Math.max(0, window.innerWidth - width - EDGE_GAP_PX); + const maxTop = Math.max(MIN_TOP_PX, window.innerHeight - headerHeight - EDGE_GAP_PX); + return { + left: Math.min(Math.max(left, EDGE_GAP_PX), maxLeft), + top: Math.min(Math.max(top, MIN_TOP_PX), maxTop), + }; + } + + /** + * 자리를 인라인 좌표로 박고, 아래 공간이 모자라면 헤더 **위로** 펼치게 뒤집는다. + * 뒤집을 때는 `bottom` 기준으로 잡아 헤더가 놓아 둔 자리에 그대로 남는다. + */ + function apply(next: PanelPosition): void { + position = next; + const headerHeight = header.offsetHeight; + const body = bodyHeight(); + const spaceBelow = window.innerHeight - (next.top + headerHeight) - EDGE_GAP_PX; + const spaceAbove = next.top - MIN_TOP_PX; + const flip = body > spaceBelow && spaceAbove > spaceBelow; + + root.style.left = `${next.left}px`; + root.style.right = "auto"; + root.classList.toggle("is-flip-up", flip); + if (flip) { + root.style.top = "auto"; + root.style.bottom = `${Math.max(window.innerHeight - next.top - headerHeight, 0)}px`; + } else { + root.style.bottom = "auto"; + root.style.top = `${next.top}px`; + } + sessionStorage.setItem(storageKey, JSON.stringify(next)); + } + + function swallowNextClick(): void { + const handler = (event: MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + }; + window.addEventListener("click", handler, { capture: true, once: true }); + // 화면 밖에서 손을 떼면 click 이 안 온다 — 남은 감시자가 다음 진짜 클릭을 + // 먹지 않도록 곧 걷어낸다. click 은 pointerup 과 같은 입력 처리 차례에 오므로 + // 타이머(0ms)는 늘 그 뒤에 돈다. + window.setTimeout(() => window.removeEventListener("click", handler, true), 0); + } + + header.addEventListener("pointerdown", (event: PointerEvent) => { + if (event.button !== 0) return; + const rect = root.getBoundingClientRect(); + pointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + baseLeft = rect.left; + // 뒤집힌 상태에서도 기준은 늘 헤더의 위쪽 변이다. + baseTop = header.getBoundingClientRect().top; + moved = false; + // 여기서 포인터를 잡으면 뒤따르는 click 의 대상이 제목에서 헤더로 바뀌어 + // 접기/펴기 토글이 죽는다(2026-09-04 실측). 실제로 끌기 시작한 뒤에 잡는다. + }); + + // 포인터 이동은 **창 전체**에서 듣는다. 헤더에서만 들으면 커서가 패널 밖으로 + // 나가는 순간 이동이 끊기고, 헤더에 포인터를 잡아 두면(setPointerCapture) 뒤따르는 + // click 의 대상이 제목에서 헤더로 바뀌어 접기/펴기 토글이 죽는다(2026-09-04 실측). + window.addEventListener("pointermove", (event: PointerEvent) => { + if (pointerId !== event.pointerId) return; + const dx = event.clientX - startX; + const dy = event.clientY - startY; + if (!moved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; + if (!moved) { + moved = true; + // 끌기가 실제로 시작된 뒤에만 포인터를 붙잡는다. B07 CAD 화면은 iframe 이라 + // 커서가 그 위로 들어가면 바깥 window 가 이동을 못 받는다(2026-09-04 실측). + // pointerdown 시점에 잡으면 뒤따르는 click 대상이 헤더로 바뀌어 토글이 죽는다. + try { + header.setPointerCapture(event.pointerId); + } catch { + /* 이미 놓친 포인터면 그대로 진행 */ + } + } + root.classList.add("is-dragging"); + apply(clamp(baseLeft + dx, baseTop + dy)); + }); + + function endDrag(event: PointerEvent): void { + if (pointerId !== event.pointerId) return; + if (moved && header.hasPointerCapture(pointerId)) header.releasePointerCapture(pointerId); + pointerId = null; + root.classList.remove("is-dragging"); + if (!moved) return; + // 놓는 시점에 열리는 방향을 다시 판정한다. + if (position) apply(clamp(position.left, position.top)); + swallowNextClick(); + } + + window.addEventListener("pointerup", endDrag); + window.addEventListener("pointercancel", endDrag); + + // 만들어진 직후에는 아직 DOM에 붙기 전이라 실측 크기가 0이다 — 한 프레임 뒤에 앉힌다. + if (position) requestAnimationFrame(() => position && apply(clamp(position.left, position.top))); + + return { + refresh: () => { + if (position) apply(clamp(position.left, position.top)); + }, + }; +} -- 2.54.0 From ab16361125ddf538db6607e105697301b4950a40 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:04:33 +0900 Subject: [PATCH 061/441] =?UTF-8?q?feat(B03):=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9E=90=EB=A3=8C=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=BD=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EB=B2=94=EC=9C=84=20=EC=82=AC=EA=B0=81=ED=98=95?= =?UTF-8?q?=C2=B7=EB=85=B8=EC=84=A0=20=ED=98=95=EC=83=81=C2=B7=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계획노선 shapefile 분석에 `preview_path`(200점 안팎 솎은 좌표열) 추가 — 이미 메모리에 있는 정점을 쓰므로 파일 재열람 0회(솎기 0.136ms) - LAS·GeoTIFF 는 사용자 확정대로 **bbox 사각형만** (점구름·래스터 렌더 안 함) - `upload-overview` 응답에 분석 `metadata` 동봉 — 재접속해도 같은 그림 - 카드에 SVG 미리보기 렌더(신규 `B03_FileInput_UI_Preview.ts`), 값 없으면 미표시 - 계획노선 카드는 지형 자료 범위와 대조 — 벗어나면 경고색·안내, 좌표계가 다르면 대조 생략(재투영 안 함). shapefile 은 좌표계가 없어 같은 세트 `.prj` 값을 씀 검증: 공용 브라우저 실측(노선 선 121점·범위 안/밖·좌표계 상이 3갈래), tmp/tests/test_b03_preview_path.py 2건, tsc --noEmit 통과 Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Api_Fetch.ts | 2 + .../B03_FileInput_Engine_Shapefile.py | 29 +++ B03_FileInput/B03_FileInput_Repository.py | 2 +- B03_FileInput/B03_FileInput_Router.py | 14 ++ B03_FileInput/B03_FileInput_Schema.py | 3 + B03_FileInput/B03_FileInput_UI_Page.ts | 43 ++++ B03_FileInput/B03_FileInput_UI_Preview.ts | 191 ++++++++++++++++++ B03_FileInput/B03_FileInput_UI_Style.css | 42 ++++ B03_FileInput/B03_FileInput_UI_Support.ts | 3 +- ui_template/ui_template_locale_b1.ts | 10 + 10 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_UI_Preview.ts diff --git a/B03_FileInput/B03_FileInput_Api_Fetch.ts b/B03_FileInput/B03_FileInput_Api_Fetch.ts index e07dbf98..4768fecd 100644 --- a/B03_FileInput/B03_FileInput_Api_Fetch.ts +++ b/B03_FileInput/B03_FileInput_Api_Fetch.ts @@ -167,6 +167,8 @@ export interface UploadOverviewFile { uploaded_at: string | null; /** 저장 경로 — PRJ 두 장(노선/지형)을 카드에 되돌릴 때 이것으로 가린다. */ relative_path: string | null; + /** 업로드 분석 결과 — 카드 미리보기(범위 사각형·노선 선)의 재료. */ + metadata?: Record | null; } export interface UploadOverviewSession { diff --git a/B03_FileInput/B03_FileInput_Engine_Shapefile.py b/B03_FileInput/B03_FileInput_Engine_Shapefile.py index aeb56ce8..f67e94b8 100644 --- a/B03_FileInput/B03_FileInput_Engine_Shapefile.py +++ b/B03_FileInput/B03_FileInput_Engine_Shapefile.py @@ -213,6 +213,33 @@ def _route_name_from_attributes(attributes: dict[str, str], fallback: str) -> st return fallback +_PREVIEW_MAX_POINTS = 200 + + +def _thin_preview_path( + parts: list[list[tuple]], limit: int = _PREVIEW_MAX_POINTS +) -> list[list[list[float]]]: + """카드 미리보기용으로 노선 정점을 솎는다 — 파일을 다시 읽지 않는다. + + 전 정점은 이미 메모리에 있고(`read_shapefile_parts`), 화면에 그릴 선은 60~80px + 높이라 200점이면 모양이 충분히 산다. 파트별로 시작·끝점은 반드시 남긴다. + """ + total = sum(len(part) for part in parts) + if total == 0: + return [] + step = max(1, total // limit) + preview: list[list[list[float]]] = [] + for part in parts: + if not part: + continue + thinned = [[float(point[0]), float(point[1])] for point in part[::step]] + last = [float(part[-1][0]), float(part[-1][1])] + if thinned[-1] != last: + thinned.append(last) + preview.append(thinned) + return preview + + def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: """계획노선 shapefile의 B03 메타데이터를 만든다.""" source = Path(path) @@ -241,4 +268,6 @@ def analyze_shapefile_metadata(path: str | Path) -> dict[str, Any]: "bounds": header["bounds"], "start_point": list(parts[0][0]) if parts else None, "end_point": list(parts[-1][-1]) if parts else None, + # 카드 미리보기용 솎은 좌표열 — 파일 재열람 없음(2026-09-04 사용자 지시). + "preview_path": _thin_preview_path(parts), } diff --git a/B03_FileInput/B03_FileInput_Repository.py b/B03_FileInput/B03_FileInput_Repository.py index de194576..1c73b7dc 100644 --- a/B03_FileInput/B03_FileInput_Repository.py +++ b/B03_FileInput/B03_FileInput_Repository.py @@ -327,7 +327,7 @@ async def list_project_input_files( await cursor.execute( """ SELECT f.id, f.file_type, f.original_filename, f.file_size_mb, f.status, - f.upload_at, f.raw_file_path + f.upload_at, f.raw_file_path, f.metadata FROM input_files f INNER JOIN ( SELECT MAX(id) AS id diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index d63f236c..222b0666 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -781,6 +781,19 @@ async def get_project_upload_status( ) +def _parse_metadata(raw: Any) -> dict[str, Any] | None: + """DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략).""" + if isinstance(raw, dict): + return raw + if not raw: + return None + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None + + @router.get("/{project_id}/upload-overview", response_model=UploadOverviewResponse) async def get_project_upload_overview( project_id: UUID, @@ -821,6 +834,7 @@ async def get_project_upload_overview( status=str(row["status"]), uploaded_at=str(row["upload_at"]) if row.get("upload_at") else None, relative_path=(str(row["raw_file_path"]) if row.get("raw_file_path") else None), + metadata=_parse_metadata(row.get("metadata")), ) for row in files ], diff --git a/B03_FileInput/B03_FileInput_Schema.py b/B03_FileInput/B03_FileInput_Schema.py index 18d24b7a..5211862d 100644 --- a/B03_FileInput/B03_FileInput_Schema.py +++ b/B03_FileInput/B03_FileInput_Schema.py @@ -131,6 +131,9 @@ class UploadOverviewFile(BaseModel): # PRJ는 노선용·지형용 두 장이 온다. 확장자로는 못 가리므로 저장 폴더로 가린다 # (노선 세트는 `B03_FileInput/input/shp/`에 모인다, 2026-08-31). relative_path: str | None = None + # 업로드 분석기가 낸 메타데이터 — 카드 미리보기(범위 사각형·노선 선)를 그리는 재료다 + # (2026-09-04). 재접속해도 같은 그림이 서도록 서버 정본을 그대로 실어 보낸다. + metadata: dict[str, Any] | None = None class UploadOverviewSession(BaseModel): diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 4a74dc82..307eaaf7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -25,6 +25,12 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState } from "./B03_FileInput_State"; +import { + readCrsLabel, + readExtent, + renderSlotPreview, + type PreviewExtent, +} from "./B03_FileInput_UI_Preview"; import { confirmReplaceUpload, isInitialPipelineRunning, @@ -242,9 +248,45 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (state.error) setCardState(slot, "failed"); else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty"); else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus); + + const preview = card.querySelector(".b03-file__preview"); + if (preview) { + const terrain = terrainCoverage(); + renderSlotPreview(preview, { + metadata: state.serverUploaded?.metadata, + terrainExtent: ROUTE_SLOTS.includes(slot) ? terrain.extent : null, + terrainCrs: terrain.crs, + crsFallback: readCrsLabel(slots.get("route_prj")?.serverUploaded?.metadata), + }); + } updateUploadButton(); } + /** + * 지형 자료(라이다·GeoTIFF)가 덮는 범위를 하나로 합친다 — 계획노선이 그 안에 + * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 + * 카드에서 바로 보이게 한다(2026-09-04). + */ + function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } { + let extent: PreviewExtent | null = null; + let crs: string | null = null; + for (const slot of TERRAIN_SLOTS) { + const metadata = slots.get(slot)?.serverUploaded?.metadata; + const next = readExtent(metadata); + if (!next) continue; + crs ??= readCrsLabel(metadata); + extent = extent + ? { + xMin: Math.min(extent.xMin, next.xMin), + xMax: Math.max(extent.xMax, next.xMax), + yMin: Math.min(extent.yMin, next.yMin), + yMax: Math.max(extent.yMax, next.yMax), + } + : next; + } + return { extent, crs }; + } + function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; @@ -447,6 +489,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { state.serverUploaded = { name: file.original_filename, sizeMb: file.file_size_mb, + metadata: file.metadata ?? undefined, }; } } diff --git a/B03_FileInput/B03_FileInput_UI_Preview.ts b/B03_FileInput/B03_FileInput_UI_Preview.ts new file mode 100644 index 00000000..4c893c10 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Preview.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * B03_FileInput_UI_Preview.ts + * 업로드 자료 카드 미리보기 — 분석 메타데이터의 범위(bounds)만으로 그리는 작은 SVG. + * + * 재료는 이미 서버가 낸다(업로드 분석기의 `bounds`, 계획노선 shapefile은 `preview_path`). + * **파일을 다시 읽지 않으므로 추가 지연이 없다** — 사용자 제약 「오래 걸리면 안 됨」 + * (2026-09-04 확정). 라이다 점구름·GeoTIFF 래스터는 **범위 사각형만** 그린다. + * ========================================================================== */ + +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export interface PreviewExtent { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +const SVG_NS = "http://www.w3.org/2000/svg"; +const VIEW_WIDTH = 100; +const VIEW_HEIGHT = 60; +const PADDING = 4; + +function finite(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +/** + * 분석기마다 범위를 담는 모양이 다르다 — 세 가지를 한 형태로 모은다. + * shapefile·노선 CSV `{x_min,…}` / LAS `{x:[min,max],…}` / GeoTIFF `{left,bottom,right,top}`. + */ +export function readExtent(metadata: Record | undefined): PreviewExtent | null { + const bounds = metadata?.bounds as Record | undefined; + if (!bounds) return null; + + const pairs: [number | null, number | null, number | null, number | null][] = [ + [finite(bounds.x_min), finite(bounds.x_max), finite(bounds.y_min), finite(bounds.y_max)], + [finite(bounds.left), finite(bounds.right), finite(bounds.bottom), finite(bounds.top)], + ]; + const xs = bounds.x as unknown[] | undefined; + const ys = bounds.y as unknown[] | undefined; + if (Array.isArray(xs) && Array.isArray(ys)) { + pairs.push([finite(xs[0]), finite(xs[1]), finite(ys[0]), finite(ys[1])]); + } + for (const [xMin, xMax, yMin, yMax] of pairs) { + if (xMin === null || xMax === null || yMin === null || yMax === null) continue; + if (xMax <= xMin || yMax <= yMin) continue; + return { xMin, xMax, yMin, yMax }; + } + return null; +} + +/** 계획노선 shapefile이 함께 낸 솎은 좌표열(없으면 null). */ +export function readPreviewPath( + metadata: Record | undefined, +): number[][][] | null { + const raw = metadata?.preview_path; + if (!Array.isArray(raw) || raw.length === 0) return null; + const parts: number[][][] = []; + for (const part of raw) { + if (!Array.isArray(part)) continue; + const points: number[][] = []; + for (const point of part) { + if (!Array.isArray(point)) continue; + const x = finite(point[0]); + const y = finite(point[1]); + if (x !== null && y !== null) points.push([x, y]); + } + if (points.length >= 2) parts.push(points); + } + return parts.length ? parts : null; +} + +/** + * 좌표계 코드(EPSG 숫자). 서로 다르면 범위 대조를 하지 않는다. + * 분석기마다 숫자(`5176`)로도, 문자열(`"EPSG:5176"`)로도 온다 — 숫자만 뽑아 맞춘다. + */ +export function readCrsLabel(metadata: Record | undefined): string | null { + const epsg = metadata?.epsg ?? metadata?.crs_epsg ?? metadata?.crs; + if (typeof epsg === "number" && Number.isFinite(epsg)) return String(epsg); + if (typeof epsg !== "string") return null; + const digits = epsg.match(/\d{4,6}/); + return digits ? digits[0] : null; +} + +/** a 가 b 안에 들어오는가(경계 포함). */ +export function isInside(inner: PreviewExtent, outer: PreviewExtent): boolean { + return ( + inner.xMin >= outer.xMin && + inner.xMax <= outer.xMax && + inner.yMin >= outer.yMin && + inner.yMax <= outer.yMax + ); +} + +/** 자기 범위를 화면 좌표(위가 북쪽)로 옮기는 변환을 만든다. */ +function projector(extent: PreviewExtent): (x: number, y: number) => [number, number] { + const spanX = extent.xMax - extent.xMin; + const spanY = extent.yMax - extent.yMin; + const scale = Math.min((VIEW_WIDTH - PADDING * 2) / spanX, (VIEW_HEIGHT - PADDING * 2) / spanY); + const offsetX = (VIEW_WIDTH - spanX * scale) / 2; + const offsetY = (VIEW_HEIGHT - spanY * scale) / 2; + return (x, y) => [ + offsetX + (x - extent.xMin) * scale, + // SVG 는 y 가 아래로 자라므로 뒤집는다. + VIEW_HEIGHT - offsetY - (y - extent.yMin) * scale, + ]; +} + +export interface PreviewOptions { + metadata?: Record; + /** 지형 자료 범위 — 계획노선 카드에서 「범위 안인가」를 대조할 때만 쓴다. */ + terrainExtent?: PreviewExtent | null; + /** 지형 자료 좌표계 — 노선과 다르면 대조를 생략한다(재투영은 하지 않는다). */ + terrainCrs?: string | null; + /** + * 이 자료의 좌표계를 대신 알려 준다. shapefile 자체에는 좌표계가 없어 + * 같은 세트의 `.prj` 값이 정본이다(2026-09-04 실측 — `epsg: null`). + */ + crsFallback?: string | null; +} + +/** + * 카드 본문에 미리보기를 그린다. 그릴 것이 없으면 비우고 숨긴다(값 없으면 미표시). + */ +export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): void { + host.replaceChildren(); + host.classList.remove("is-visible", "is-warning"); + host.removeAttribute("title"); + + const extent = readExtent(options.metadata); + if (!extent) return; + + const path = readPreviewPath(options.metadata); + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.setAttribute("role", "img"); + const project = projector(extent); + + // 자료가 덮는 땅 범위 — 어느 자료든 늘 그린다. + const [rectX, rectTop] = project(extent.xMin, extent.yMax); + const [rectRight, rectBottom] = project(extent.xMax, extent.yMin); + const rect = document.createElementNS(SVG_NS, "rect"); + rect.setAttribute("class", "b03-file__preview-extent"); + rect.setAttribute("x", String(rectX)); + rect.setAttribute("y", String(rectTop)); + rect.setAttribute("width", String(rectRight - rectX)); + rect.setAttribute("height", String(rectBottom - rectTop)); + svg.append(rect); + + let label = L("B03_File_Preview_Extent"); + if (path) { + label = L("B03_File_Preview_Route"); + for (const part of path) { + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("class", "b03-file__preview-route"); + line.setAttribute( + "points", + part + .map(([x, y]) => + project(x, y) + .map((value) => value.toFixed(2)) + .join(","), + ) + .join(" "), + ); + svg.append(line); + } + } + + // 노선이 지형 자료 범위를 벗어나면 카드에서 바로 보이게 경고색을 건다. + // 솎은 좌표열이 없어도(옛 업로드) 범위끼리는 대조된다. + // 좌표계가 다르면 대조하지 않는다(프론트에서 재투영하지 않음). + const routeCrs = readCrsLabel(options.metadata) ?? options.crsFallback ?? null; + const sameCrs = !routeCrs || !options.terrainCrs || routeCrs === options.terrainCrs; + if (options.terrainExtent && sameCrs) { + const inside = isInside(extent, options.terrainExtent); + host.classList.toggle("is-warning", !inside); + label = L(inside ? "B03_File_Preview_Inside" : "B03_File_Preview_Outside"); + } + + svg.setAttribute("aria-label", label); + host.title = label; + host.append(svg); + host.classList.add("is-visible"); +} diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index ac1f2dcc..5c9f6146 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -590,11 +590,53 @@ } .b03-file__card--empty .b03-file__file-info, +.b03-file__card--empty .b03-file__preview, .b03-file__card--empty .b03-file__progress-section, .b03-file__card--empty .b03-file__error-message { display: none; } +/* 업로드 자료 미리보기(2026-09-04) — 분석 메타데이터의 범위만으로 그리는 작은 도형. + 값이 없으면 `is-visible` 이 안 붙어 자리를 차지하지 않는다. */ +.b03-file__preview { + display: none; + margin-top: 6px; +} + +.b03-file__preview.is-visible { + display: block; +} + +.b03-file__preview svg { + display: block; + width: 100%; + height: 60px; +} + +.b03-file__preview-extent { + fill: color-mix(in srgb, var(--color-border, #9aa) 35%, transparent); + stroke: var(--color-border, #9aa); + stroke-width: 1; +} + +.b03-file__preview-route { + fill: none; + stroke: var(--color-primary, #4a3aff); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; +} + +/* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ +.b03-file__preview.is-warning .b03-file__preview-route { + stroke: var(--color-danger, #d33); +} + +.b03-file__preview.is-warning .b03-file__preview-extent { + stroke: var(--color-danger, #d33); + stroke-dasharray: 3 2; +} + .b03-file__card--selected .b03-file__progress-section { display: none; } diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 7b4e1707..609e9fad 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -39,7 +39,7 @@ export interface FileSlotState extends SlotConfig { * 로컬 파일을 새로 고르지 않아도 카드에 완료 상태로 표시하고, 이 슬롯에 새 파일을 * 올리면 교체 확인 모달을 띄우는 근거가 된다(2026-08-04 사용자 지시). */ - serverUploaded?: { name: string; sizeMb: number }; + serverUploaded?: { name: string; sizeMb: number; metadata?: Record }; } export interface StoredUploadSession { @@ -219,6 +219,7 @@ export function createFileCardTemplate(): HTMLTemplateElement {
+
diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index cbcaa673..e73e12d1 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -346,6 +346,16 @@ export const ui_locales_b1 = { "Files moved, but analysis did not start (no point cloud file).", ], + B03_File_Preview_Extent: ["자료 범위", "Data extent"], + B03_File_Preview_Route: ["계획노선 형상", "Planned route shape"], + B03_File_Preview_Outside: [ + "계획노선이 지형 자료 범위를 벗어납니다.", + "The planned route falls outside the terrain data extent.", + ], + B03_File_Preview_Inside: [ + "계획노선이 지형 자료 범위 안에 있습니다.", + "The planned route is inside the terrain data extent.", + ], B03_File_Card_Select: ["파일 선택", "Select file"], B03_File_Card_Remove: ["파일 제거", "Remove file"], B03_File_Card_Optional: ["선택", "Optional"], -- 2.54.0 From 98db2731b94f0aa59f247279e1195f1dca089f23 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:06:32 +0900 Subject: [PATCH 062/441] =?UTF-8?q?style(B03):=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B2=94=EC=9C=84=20=EC=82=AC=EA=B0=81?= =?UTF-8?q?=ED=98=95=20=EC=98=85=EC=9D=80=20=EC=A0=90=EC=84=A0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 어두운 테마에서 채움 35%가 회색 덩어리로 보여 범위인지 안 읽힘 — 채움 12% + 점선 테두리로 「범위」임이 드러나게 함(실측 스크린샷 확인). Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 5c9f6146..b0e1e9ef 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -614,8 +614,9 @@ } .b03-file__preview-extent { - fill: color-mix(in srgb, var(--color-border, #9aa) 35%, transparent); + fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent); stroke: var(--color-border, #9aa); + stroke-dasharray: 4 3; stroke-width: 1; } -- 2.54.0 From ecb3bcb9ce2e73a71d96a430423d1bc2c5b3125c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:11:41 +0900 Subject: [PATCH 063/441] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=C2=B7=EC=9D=B4=EB=A0=A5=20=ED=95=9C=20?= =?UTF-8?q?=EB=8D=A9=EC=96=B4=EB=A6=AC=C2=B7=EC=89=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=ED=95=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방향키 청취를 패널 루트에서 window 로 옮김 — 그래프 클릭 뒤 포커스가 body 에 남아 방향키가 아무 반응이 없던 결함 수정 (패널이 화면에 없으면 무시) - 길게 누르면 편집 버튼과 같은 속도로 연속 이동 (0.5초 뒤 0.1초 간격, OS 키 반복 무시) - 이력에 pause()/resume() 추가 — 연속 이동 전체가 되돌리기 1회로 원위치. 구조물 이동은 서버 재계산이 뒤따르므로 키를 뗀 뒤 700ms 뒤 기록 - pause() 에서도 관 목록을 세션에 흘려 되돌리기 기준을 조작 전 값으로 고정 - 좌우 연속 이동 중에는 처음 잡은 구조물 측점·자체 누가거리 카운터 사용 — 관 측점 id 가 누가거리 기반이라 한 번 옮기면 선택이 풀려 한 칸만 움직이던 결함 수정 - ProfileTools.nudge(delta) 노출 — [쉬프트] 모드의 상·하 방향키가 도구 ▲▼ 와 같은 경로(onShift)로 구간 평행이동 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 4 +- B05_Profile/B05_Profile_UI_Profile_History.ts | 16 ++- .../B05_Profile_UI_Profile_Panel_Tools.ts | 135 +++++++++++++++--- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 15 +- 4 files changed, 144 insertions(+), 26 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index dd5325b8..0b6ce3e4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,8 +23,8 @@ const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ -const HOLD_DELAY_MS = 500; -const HOLD_INTERVAL_MS = 100; +export const HOLD_DELAY_MS = 500; +export const HOLD_INTERVAL_MS = 100; /** * 길게 누르는 동안 같은 동작을 반복한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 701ae660..b1b41cd0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -75,6 +75,10 @@ function restoreSnapshot(snapshot: HistorySnapshot): void { export interface ProfileHistory { /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ record(): void; + /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ + pause(): void; + /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ + resume(): void; undo(): boolean; redo(): boolean; canUndo(): boolean; @@ -92,6 +96,8 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ let restoring = false; + /** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */ + let paused = false; function apply(index: number): boolean { if (index < 0 || index >= stack.length) return false; @@ -108,7 +114,7 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile return { record() { - if (restoring) return; + if (restoring || paused) return; const snapshot = takeSnapshot(); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); @@ -116,6 +122,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile if (stack.length > limit) stack.shift(); cursor = stack.length - 1; }, + pause() { + paused = true; + }, + resume() { + if (!paused) return; + paused = false; + this.record(); + }, undo: () => apply(cursor - 1), redo: () => apply(cursor + 1), canUndo: () => cursor > 0, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index f886aa09..8bdb8b2a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -15,6 +15,7 @@ import type { ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { @@ -99,8 +100,14 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { ctx.restore(); restorePipes(); }); - /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ - const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. + * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** + * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + const history: ProfileHistory = { + ...inner, + record: () => (syncPipes(), inner.record()), + pause: () => (syncPipes(), inner.pause()), + }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { @@ -173,14 +180,110 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 - * 측점번호가 어긋난다(2026-09-02 사용자 확정). */ - ctx.root.addEventListener("keydown", (event) => { + * 측점번호가 어긋난다(2026-09-02 사용자 확정). + * 누르고 있으면 편집 버튼과 **같은 속도**로 이어진다(0.5초 뒤 초당 10회) — OS 키 반복에 + * 맡기면 기기마다 속도가 갈린다. 그 동안의 변화는 이력 한 덩어리다(2026-09-04 사용자 지시). */ + type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + + /** 방향키 한 번의 조작 — 반복 타이머가 같은 함수를 다시 부른다. 대상이 없으면 false. */ + function nudge(key: ArrowKey): boolean { + const base = ctx.base(); + if (!base) return false; + if (key === "ArrowUp" || key === "ArrowDown") { + const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; + // [쉬프트]가 켜져 있으면 고른 직선 구간의 평행이동 — 도구 ▲▼와 같은 경로다. + if (tools.mode() === "shift") return tools.nudge(delta); + const chainage = selectedChainage(); + if (chainage === null) return false; + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + return true; + } + // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 + // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). + const station = holdStation ?? selectedIrregular(); + if (!station) return false; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 목록(정본)은 서버 재계산 뒤에야 새 누가거리를 들고 온다 — 연속 이동 중에는 그것을 + // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 + // 0.1m 만 움직이고 멈춤(2026-09-04 실측). + const fromM = holdChainageM ?? station.chainage_m; + const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); + if (next < 0) return false; + ctx.moveStation({ ...station, chainage_m: fromM }, next); + holdStation = station; + holdChainageM = next; + history.record(); + return true; + } + + let heldKey: ArrowKey | null = null; + /** 좌우 연속 이동 중의 현재 누가거리 — 정본 목록이 따라오기 전까지 여기서 센다. */ + let holdChainageM: number | null = null; + /** 옮기는 중인 구조물 측점 — 선택이 풀려도 키를 뗄 때까지 이 측점을 움직인다. */ + let holdStation: IrregularStation | null = null; + let holdDelayTimer = 0; + let holdRepeatTimer = 0; + + /** 구조물 이동은 서버 재계산을 거쳐 돌아오고 그 뒷정리(유령 변화점 삭제)도 이력을 + * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ + const HOLD_SETTLE_MS = 700; + let settleTimer = 0; + + /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ + function stopHold(): void { + window.clearTimeout(holdDelayTimer); + window.clearInterval(holdRepeatTimer); + holdDelayTimer = 0; + holdRepeatTimer = 0; + if (heldKey === null) return; + heldKey = null; + holdChainageM = null; + holdStation = null; + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", stopHold); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + history.resume(); + }, HOLD_SETTLE_MS); + } + + /** 기다리지 않고 지금 바로 한 덩어리를 닫는다(다음 조작·되돌리기 직전). */ + function flushHold(): void { + stopHold(); + if (!settleTimer) return; + window.clearTimeout(settleTimer); + settleTimer = 0; + history.resume(); + } + + function onKeyUp(event: KeyboardEvent): void { + if (event.key === heldKey) stopHold(); + } + + /** 첫 한 번은 부른 쪽에서 이미 움직였다 — 여기서는 이어지는 반복만 건다. */ + function startHold(key: ArrowKey): void { + heldKey = key; + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", stopHold); + holdDelayTimer = window.setTimeout(() => { + holdRepeatTimer = window.setInterval(() => { + if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. + }, HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + + /* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에 + * 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다 + * (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */ + window.addEventListener("keydown", (event) => { + if (!ctx.root.isConnected) return; const target = event.target as HTMLElement | null; // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { event.preventDefault(); + flushHold(); if (event.shiftKey) history.redo(); else history.undo(); return; @@ -188,24 +291,20 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { return; } - const chainage = selectedChainage(); - const base = ctx.base(); - if (chainage === null || !base) return; - if (key === "ArrowUp" || key === "ArrowDown") { + // OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다. + if (event.repeat) { event.preventDefault(); - const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); return; } - const station = selectedIrregular(); - if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다. + flushHold(); + history.pause(); + if (!nudge(key)) { + history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다. + return; + } event.preventDefault(); - const next = Number( - (station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3), - ); - if (next < 0) return; - ctx.moveStation(station, next); - history.record(); + startHold(key); }); return { tools, history, handleToolPick }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 47e1e13f..0487a666 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -50,6 +50,8 @@ export interface ProfileTools { selectedRuns: () => StraightRun[]; /** 직선화 대기 중 첫 측점(강조 표시용). */ pendingStation: () => number | null; + /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ + nudge: (delta: number) => boolean; /** 모드·선택을 모두 끈다. */ clear: () => void; } @@ -89,16 +91,19 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo callbacks.onChanged(); } - function step(delta: number): void { + function step(delta: number): boolean { if (mode === "shift" && runs.length) { callbacks.onShift(runs, delta); - return; + return true; } - if (runs.length === 1) callbacks.onTilt(runs[0], delta); + if (runs.length !== 1) return false; + callbacks.onTilt(runs[0], delta); + return true; } return { mode: () => mode, + nudge: step, selectedRuns: () => runs, pendingStation: () => pending, clear() { @@ -170,8 +175,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo ); shift.classList.toggle("is-active", mode === "shift"); - const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1)); - const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1)); + const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); + const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); const idle = mode === "none" || (!runs.length && mode === "shift"); up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled; -- 2.54.0 From 2d49c593427a458decdc8ecd3d168fcc9bebb058 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:23:34 +0900 Subject: [PATCH 064/441] =?UTF-8?q?feat(B05):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=EC=A4=8C=C2=B7Y=20=ED=91=9C=EC=8B=9C=20=ED=91=9C?= =?UTF-8?q?=EA=B3=A0=EC=B0=BD=20=EC=A1=B0=EC=9E=91=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile_UI_Profile_Zoom.ts 신규 — 배율 상태(가로 폭 배수·세로 배율·창 중심)와 버튼 7개(+ − ⇕+ ⇕− ▲ ▼ ⤢). 자리는 요약줄 오른쪽 끝(그래프 우측 상단), 설명은 툴팁, 클릭은 stopPropagation 으로 측점 선택에 번지지 않게 함 - X 줌은 폭 배수 — computeProfileLayout 에 zoomX 를 넘겨 측점 간격·폭맞춤 폭에 함께 곱함. 그래프·테이블·편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 넷의 x 정렬이 유지됨 - Y 는 표시 표고창(제안 A) — createLongitudinalProfile 에 elevationOffsetRatio 인자 추가, 창 높이 = 전범위 ÷ 배율, ▲▼ 가 창 높이의 10%씩 중심 이동. 세로 스크롤 없음 - 지반선·계획선·절성토 음영을 플롯 사각형 clipPath 안으로 옮김 — 배율을 올려도 선이 축·측점 라벨 위로 흘러나오지 않음 (B06 은 배율 1·오프셋 0 이라 표시 불변) - 요약줄 렌더러에 trailing 자리 추가, .b05-profile__zoom* CSS 추가 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 5 + B05_Profile/B05_Profile_UI_Profile_Layout.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 6 ++ B05_Profile/B05_Profile_UI_Profile_Render.ts | 17 +++- B05_Profile/B05_Profile_UI_Profile_Zoom.ts | 93 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Style.css | 36 +++++++ B06_Section/B06_Section_UI_Longitudinal.ts | 47 ++++++++-- 7 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_Zoom.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index f6166abe..3d5732bf 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -34,6 +34,8 @@ export interface BalanceBarParams { tools?: HTMLElement; /** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */ minCoverViolations?: MinCoverViolation[]; + /** 요약줄 **오른쪽 끝**에 붙이는 묶음(줌·Y레인지 조작구) — 2026-09-04. */ + trailing?: HTMLElement; /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -50,6 +52,7 @@ export function renderBalanceBar(params: BalanceBarParams): void { "⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요."; params.balanceBar.append(note); } + if (params.trailing) params.balanceBar.append(params.trailing); return; } const { alignment } = params; @@ -106,4 +109,6 @@ export function renderBalanceBar(params: BalanceBarParams): void { badge.textContent = "미저장 (확정 시 반영)"; params.balanceBar.append(badge); } + // 줌 조작구는 늘 오른쪽 끝 — 그래프 우측 상단 자리다(2026-09-04 사용자 지시). + if (params.trailing) params.balanceBar.append(params.trailing); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index 8a2a50b0..987aa863 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -113,14 +113,18 @@ export function computeProfileLayout( data: LongitudinalSection, stationIntervalM: number, availableWidth: number, + /** 가로 줌 배수(1 = 현행). 측점 간격 기본값과 폭맞춤 폭에 함께 곱해 넷(그래프·테이블· + * 편집 버튼층·구조물 레인)이 같은 비율로 늘어나게 한다(2026-09-04 사용자 지시). */ + zoomX = 1, ): ProfileLayout { const maxChainage = maxChainageOf(data); const interval = Math.max(stationIntervalM, 1e-6); const framePad = LONG_PAD.left + LONG_PAD.right; - const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER; + const zoom = Math.max(zoomX, 1e-6); + const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER * zoom; // 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함). const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing; - const width = Math.max(minWidth, availableWidth); + const width = Math.max(minWidth, availableWidth * zoom); const pxPerMeter = (width - framePad) / (maxChainage + interval); const spacing = interval * pxPerMeter; return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 37e81e3d..08880c4b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -56,6 +56,7 @@ import { renderProfile } from "./B05_Profile_UI_Profile_Render"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview"; import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools"; +import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom"; import { stationIdAtStructure as stationIdAtStructureOf, structureIdAtStation as structureIdAtStationOf, @@ -359,6 +360,7 @@ export function createRouteProfilePanel( minCoverViolations, balanceBar, tools: tools.render(), + trailing: profileZoom.bar, alignment, legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal), edited: store.edited(), @@ -371,6 +373,9 @@ export function createRouteProfilePanel( }); } + /* 줌·Y레인지 조작구 — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다. */ + const profileZoom = createProfileZoom(() => draw()); + /* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */ const { tools, history, handleToolPick } = createPanelTools({ root, @@ -506,6 +511,7 @@ export function createRouteProfilePanel( selectStation, applyEdits, handleToolPick, + zoom: profileZoom.state, toolActive: () => tools.mode() !== "none", selectedRuns: () => tools.selectedRuns(), stationIdAtStructure, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 10e6018d..383a4cfe 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -27,6 +27,7 @@ import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; +import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { CELL_GAP_PX, @@ -83,6 +84,8 @@ export interface ProfileRenderContext { applyEdits: (next: AlignmentEdits) => void; /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ handleToolPick: (chainageM: number | null) => boolean; + /** 가로 폭 배수·세로 표시 표고창(줌 조작구 상태) — 2026-09-04. */ + zoom: () => ProfileZoomState; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ @@ -114,15 +117,20 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.renderBalance(); const longitudinal = detail.longitudinal; + const zoom = ctx.zoom(); + // 가로 줌은 **폭 배수**다 — 캔버스가 넓어지고 가로 스크롤로 훑는다. 그래프·테이블· + // 편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 폭 하나만 키우면 넷이 함께 늘어난다. const availableWidth = Math.max(1, body.clientWidth - 15); // 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면 // 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다. const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m; const layout = alignment && stationIntervalM - ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth) + ? computeProfileLayout(longitudinal, stationIntervalM, availableWidth, zoom.x) : { - width: Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)), + width: + Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)) * + zoom.x, originOffset: 0, cellWidth: STATION_SPACING_PX - CELL_GAP_PX, }; @@ -219,7 +227,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { createLongitudinalProfile( graphLongitudinal, selectedStationId, - 1, + // 세로 배율 = 표시 표고창 높이의 역수(1 = 표고 전범위). + zoom.y, undefined, ctx.selectStation, stationInterval, @@ -243,6 +252,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다 // (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지. 15, + // 표시 표고창의 중심 이동(창 높이 대비 비율) — ▲▼ 버튼이 옮긴다. + zoom.offsetRatio, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Zoom.ts b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts new file mode 100644 index 00000000..b6d13d86 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Zoom.ts @@ -0,0 +1,93 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Zoom.ts + * 종단면도 줌·Y레인지 조작구 (2026-09-04 사용자 지시). + * + * 공사 범위가 넓고 고저차가 크면 종단 그래프가 눌려 읽히지 않는다. 조작은 두 갈래다. + * + * X (가로) — **폭 배수**다. SVG transform 으로 늘리면 그래프만 커지고 측점 테이블· + * 계획고 편집 버튼층·구조물 알약 레인이 어긋난다(넷이 같은 `chainageMapper` + * 를 쓴다). 캔버스 폭 자체를 키우고 가로 스크롤로 훑는다. + * Y (세로) — **표시 표고창**이다(제안 A, 2026-09-04 사용자 확정). 창 높이 = 전범위 ÷ 배율, + * 창 중심은 창 높이 대비 비율로 위·아래로 옮긴다. 세로 스크롤이 생기지 않아 + * X축·측점 라벨·편집 버튼이 항상 바닥에 남는다. + * + * 배율은 페이지가 들고 있다 — 편집·재계산으로 다시 그려도 유지된다(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 = 현행 폭맞춤). */ + x: number; + /** 세로 배율(1 = 표고 전범위). */ + y: number; + /** 창 중심 이동 — 창 높이 대비 비율(+ 가 위쪽). */ + offsetRatio: number; +} + +export interface ProfileZoom { + /** 절·성토 요약줄 오른쪽 끝에 붙는 버튼 묶음(한 번 만들어 계속 쓴다). */ + bar: HTMLElement; + state: () => ProfileZoomState; +} + +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 bar = document.createElement("div"); + bar.className = "b05-profile__zoom"; + + function add(label: string, title: string, action: () => void): void { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b05-profile__zoom-btn"; + button.textContent = label; + button.title = title; + button.addEventListener("click", (event) => { + // 카드·측점 선택으로 번지면 그래프를 다시 그리며 방금 맞춘 배율이 날아간다. + event.stopPropagation(); + action(); + onChange(); + }); + bar.append(button); + } + + add("+", "가로 확대 — 측점 간격을 넓혀 폅니다 (가로 스크롤로 훑음)", () => { + state.x = clamp(state.x * ZOOM_STEP, 1, MAX_X); + }); + 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("⤢", "가로·세로 배율과 표시 표고창을 처음 상태로", () => { + state.x = 1; + state.y = 1; + state.offsetRatio = 0; + }); + + return { bar, state: () => ({ ...state }) }; +} diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index af9ef682..dc67478c 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -1341,3 +1341,39 @@ background: color-mix(in srgb, var(--color-primary) 14%, transparent); outline: 1px solid var(--color-primary); } + +/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── + * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 + * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 + * 여기서 같은 값으로 다시 적는다. */ +.b05-profile__zoom { + display: flex; + flex: 0 0 auto; + margin-left: auto; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b05-profile__zoom-btn { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.b05-profile__zoom-btn:first-child { + border-left: none; +} + +.b05-profile__zoom-btn:hover { + color: var(--color-text); + background: var(--color-surface); +} diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 0db4f492..6facaf19 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -50,7 +50,7 @@ export function longitudinalMinimumWidth( * 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다. */ function appendCutFillBands( - svg: SVGSVGElement, + svg: SVGElement, profile: DesignProfile, x: (chainage: number) => number, planY: (index: number) => number, @@ -168,6 +168,12 @@ export function createLongitudinalProfile( * LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다. */ bottomInsetPx = 0, + /** + * 세로 표시창의 중심을 위·아래로 옮기는 몫 — **창 높이 대비 비율**(0 = 현행 가운데, + * +0.1 = 창 높이의 10%만큼 위쪽을 본다). B05 종단도의 Y 레인지 조작구가 쓴다. + * 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04). + */ + elevationOffsetRatio = 0, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -207,11 +213,13 @@ export function createLongitudinalProfile( : // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다 // (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다. Math.max(rawMax - rawMin, 1) * 1.2; + // 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다. + const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration); const x = (chainage: number) => LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth; const xInverse = inverseOf(x, maxChainage); const y = (elevation: number) => - LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); // Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의 @@ -219,13 +227,13 @@ export function createLongitudinalProfile( // 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고). const yAxisTicks: Array<{ y: number; label: string }> = []; const rawSpan = elevationSpan / exaggeration; - const rawTop = elevationMid + rawSpan / 2; + const rawTop = viewCenter + rawSpan / 2; // 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다. // 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시). const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1); const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9)); for ( - let value = Math.ceil((elevationMid - rawSpan / 2) / tickStep) * tickStep; + let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep; value <= rawTop + 1e-9; value += tickStep ) { @@ -251,12 +259,29 @@ export function createLongitudinalProfile( // sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음). onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks }); + // 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 — + // 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04). + const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`; + const clipPath = svgElement("clipPath", { id: clipId }); + clipPath.append( + svgElement("rect", { + x: LONG_PAD.left, + y: LONG_PAD.top, + width: Math.max(0, widthPx - LONG_PAD.left - LONG_PAD.right), + height: Math.max(0, plotHeight), + }), + ); + const defs = svgElement("defs", {}); + defs.append(clipPath); + const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); + svg.append(defs, bandLayer); + // 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다. - const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration); + const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; appendCutFillBands( - svg, + bandLayer, profile, x, (index) => toY(profile.samples[index].elevation_m), @@ -264,7 +289,7 @@ export function createLongitudinalProfile( ); if (profile.balance_segments.length > 1) { for (const segment of profile.balance_segments.slice(1)) { - svg.append( + bandLayer.append( svgElement("line", { x1: x(segment.start_chainage_m), y1: LONG_PAD.top, @@ -342,13 +367,14 @@ export function createLongitudinalProfile( const points = samples .map((sample) => { - const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + const elevated = viewCenter + (sample.elevation_m - viewCenter) * exaggeration; return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; }) .join(" "); + const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` }); for (const profile of designProfiles) { if (profile.samples.length < 2) continue; - svg.append( + lineLayer.append( svgElement("polyline", { points: profile.samples .map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`) @@ -357,8 +383,9 @@ export function createLongitudinalProfile( }), ); } + lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" })); svg.append( - svgElement("polyline", { points, class: "b06-chart__profile" }), + lineLayer, svgElement("line", { x1: LONG_PAD.left, y1: heightPx - padBottom, -- 2.54.0 From 782139732b3836776f8a3c93e183f955ef2d0fc6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 07:31:08 +0900 Subject: [PATCH 065/441] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=ED=91=9C?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=97=B4=20=EC=83=81=EC=8B=9C?= =?UTF-8?q?=20=ED=91=9C=EA=B8=B0=C2=B7=EC=A7=81=EC=84=A0=ED=99=94=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=EA=B3=A1=EC=84=A0=EA=B0=92=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 측점 판정을 문자열 키에서 누가거리 허용 오차 0.1m 비교로 바꿈 — 구조물 목록(85.59)과 계획선 측점(85.595)이 셋째 자리에서 어긋나 구조물 열을 못 알아보고 값을 그대로 펼쳐 이웃 값과 겹쳐 보였음. 이제 고른 때만 값 열 오버레이로 보임 - 대수차 1e-4% 미만이면 직선으로 보고 곡선 L·R 을 빈 칸으로 둠 — 직선화하면 대수차가 0 이 되어 R = L ÷ |대수차| 가 수억 m 로 발산했음 (표·값 열 오버레이 공통) Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Table.ts | 71 ++++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 347b6678..85edbee0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -75,6 +75,33 @@ const CELL_PADDING_PX = 2; export const TABLE_TARGET_FONT_PX = 12; /** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ const CURVE_ROTATED_WIDTH_PX = 16; +/** + * 같은 측점으로 볼 누가거리 차이(m). + * + * 구조물 목록의 누가거리(`85.59`)와 계획선 측점의 누가거리(`85.595`)는 소수 셋째 자리에서 + * 어긋난다 — 정본을 만든 경로가 달라 최대 0.05m 벌어진다(`_Profile_Edit` 의 같은 상수 참조). + * 문자열 키로 맞추면 구조물 열을 못 알아봐 값이 그대로 펼쳐졌다(2026-09-04 실측). + */ +const SAME_STATION_TOLERANCE_M = 0.1; + +/** + * 대수차가 이 값보다 작으면 **직선**으로 본다(%). + * + * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 + * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 + * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. + */ +const STRAIGHT_DELTA_PCT = 1e-4; + +/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ +function isStraightCurve(curve: AlignmentCurve): boolean { + return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; +} + +/** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */ +function isNearChainage(chainageM: number, list: ReadonlyArray): boolean { + return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M); +} /** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */ export function tableCellWidthFor(fontPx: number): number { @@ -301,10 +328,10 @@ function curveTitle(curve: AlignmentCurve): string { function buildCurveRows( options: ProfileTableOptions, centers: number[], - /** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */ - structureStationKeys: ReadonlySet, - /** 배관 구조물 chainage 키 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ - pipeStationKeys: ReadonlySet, + /** 구조물 측점 누가거리 — 이 열의 곡선 입력은 기본 표기하지 않는다. */ + structureChainageList: ReadonlyArray, + /** 배관 구조물 누가거리 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ + pipeChainageList: ReadonlyArray, /** 기본 글자 크기(px)와 행 높이(px) — 회전 셀의 글자 축소 산식에 쓴다. */ fontPx: number, rowHeightPx: number, @@ -330,10 +357,15 @@ function buildCurveRows( // 그대로 서고, 글자 크기는 행 높이에 맞춰 줄인다(구배 행 is-rotated와 같은 방식). const curveEntries = alignment.stations.map((station, index) => { const key = station.chainage_m.toFixed(3); - const isPipe = pipeStationKeys.has(key); + const isPipe = isNearChainage(station.chainage_m, pipeChainageList); // 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지). // 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다. - const curve = structureStationKeys.has(key) && !isPipe ? undefined : curveByChainage.get(key); + const found = + isNearChainage(station.chainage_m, structureChainageList) && !isPipe + ? undefined + : curveByChainage.get(key); + // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. + const curve = found && !isStraightCurve(found) ? found : undefined; return { center: centers[index], curve, rotated: false }; }); const withCurve = curveEntries.filter((entry) => entry.curve); @@ -471,7 +503,9 @@ function buildSelectedColumn( const right = rightSeg ? pick(rightSeg).toFixed(digits) : ""; return left && right ? { left, right } : { text: left || right || "" }; }; - const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); + const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); + // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. + const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; // 거리: 바로 앞 측점(계획선 측점)까지의 간격. const previous = alignment.stations .filter((row) => row.chainage_m < chainage - 1e-6) @@ -567,15 +601,11 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다. // 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의 // chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다. - const structureStationKeys = new Set( - (options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)), - ); + const structureChainageList = (options.irregularStations ?? []).map((entry) => entry.chainage_m); // 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시). - const pipeStationKeys = new Set( - (options.irregularStations ?? []) - .filter((entry) => isPipeStation(entry)) - .map((entry) => entry.chainage_m.toFixed(3)), - ); + const pipeChainageList = (options.irregularStations ?? []) + .filter((entry) => isPipeStation(entry)) + .map((entry) => entry.chainage_m); const selectedIrregularEntry = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); @@ -596,7 +626,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. alignment.stations.forEach((station, stationIndex) => { // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. - const structural = structureStationKeys.has(station.chainage_m.toFixed(3)); + const structural = isNearChainage(station.chainage_m, structureChainageList); const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, @@ -607,7 +637,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append(row); }); table.append( - ...buildCurveRows(options, centers, structureStationKeys, pipeStationKeys, fontSize, rowHeight), + ...buildCurveRows( + options, + centers, + structureChainageList, + pipeChainageList, + fontSize, + rowHeight, + ), ); // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다. -- 2.54.0 From 3b89ba4faced1805fd0026789b8ecd3165ebeca9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 09:31:27 +0900 Subject: [PATCH 066/441] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=ED=91=9C?= =?UTF-8?q?=20=EA=B5=AC=EB=B0=B0=203=ED=96=89=20=EC=83=81=EC=8B=9C=20?= =?UTF-8?q?=ED=91=9C=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구배 L·H·S 는 값 열을 접는 규칙에서 제외 — 구조물로 좁게 갈라진 구간도 값을 적음 (2026-09-04 사용자 확정, 「각도 행」 = 구배 3행). 그 구간만 고를 때까지 값을 감추던 옛 규칙(2026-08-03)을 걷어내고, 구조물 구간 표시·하이라이트·툴팁은 유지. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Table.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 85edbee0..d0e79abc 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -265,23 +265,13 @@ function buildSegmentRows( const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); - // 구조물 배치로 갈라진 좁은 구간 값은 처음부터 보이지 않는다 — 블록·경계선·툴팁만 남기고, - // 그 측점을 **선택했을 때** 하이라이트와 함께 값을 보인다(2026-08-03 사용자 지시). + // 구배 3행(L·H·S)은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다 + // (2026-09-04 사용자 확정: 값 열을 접는 규칙에서 구배 행은 제외). + // 옛 규칙(2026-08-03: 구조물 구간은 고를 때만 값 표시)은 여기서 걷어냈고, + // 구조물 구간 표시(색·하이라이트)와 툴팁은 그대로 둔다. const structural = isStructureSegment(segment); - const highlighted = structural && touchesSelected(segment); if (structural) node.classList.add("is-structure"); - if (highlighted) node.classList.add("is-highlight"); - if (structural && !highlighted) { - node.style.left = `${left}px`; - node.style.width = `${span}px`; - node.title = - `${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선 (구조물 구간) -` + - `연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` + - `구배 ${segment.grade_percent.toFixed(2)}%`; - row.append(node); - return; - } + if (structural && touchesSelected(segment)) node.classList.add("is-highlight"); // 값은 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰 // 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다). const value = element("span", "b05-profile-table__segment-value", text); -- 2.54.0 From 370ff30738bfa7ea201db61dbfa13e05c801fd21 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:07:17 +0900 Subject: [PATCH 067/441] =?UTF-8?q?refactor(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=EC=8B=9C=ED=8A=B8=20700=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05_Profile_UI_Style.css 1379줄 -> 592줄. 규칙을 값·순서 그대로 옮겨 두 조각으로 나눔. - B05_Profile_UI_Style_Table.css (515줄) — 도면 테이블·값 열 오버레이·우클릭 메뉴· 계획고 편집 버튼·줌 조작구 - B05_Profile_UI_Style_Drainage.css (289줄) — 배수유역도 패널 진입 TS 두 곳(B05_Profile_UI_Page.ts, A00_Common/b_structures_section.ts)에서 본체 다음에 순서대로 불러 캐스케이드 순서 유지. 검증: tsc --noEmit 통과, prettier 서식 유지, 공용 브라우저(5174)에서 계산 스타일 확인 (표 이름표 sticky 78px, 구배 블록 absolute, 편집 버튼 21x17, 줌 22x22, 배수유역 flex). 종단 패널 캡처가 분리 전과 **바이트 단위로 동일**(md5 ee6bc8b52adb). Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_structures_section.ts | 3 + B05_Profile/B05_Profile_UI_Page.ts | 3 + B05_Profile/B05_Profile_UI_Style.css | 787 ------------------ B05_Profile/B05_Profile_UI_Style_Drainage.css | 289 +++++++ B05_Profile/B05_Profile_UI_Style_Table.css | 515 ++++++++++++ 5 files changed, 810 insertions(+), 787 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Style_Drainage.css create mode 100644 B05_Profile/B05_Profile_UI_Style_Table.css diff --git a/A00_Common/b_structures_section.ts b/A00_Common/b_structures_section.ts index 051169fc..653431d8 100644 --- a/A00_Common/b_structures_section.ts +++ b/A00_Common/b_structures_section.ts @@ -7,6 +7,9 @@ * 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다. * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "../B05_Profile/B05_Profile_UI_Style_Table.css"; +import "../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../B05_Profile/B05_Profile_UI_Style_Structures.css"; export { diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index eff21e17..95ba5839 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -48,6 +48,9 @@ import { type PageActionContext, } from "./B05_Profile_UI_Page_Actions"; import "./B05_Profile_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B05_Profile_UI_Style_Table.css"; +import "./B05_Profile_UI_Style_Drainage.css"; import "./B05_Profile_UI_Style_Structures.css"; import { DEFAULT_ROAD_WIDTHS, diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index dc67478c..a2f2b510 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -590,790 +590,3 @@ font-size: var(--text-caption); font-style: normal; } - -/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── - 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. - 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ -.b05-profile-table { - position: relative; - display: flex; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - color: var(--color-text-body); - /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ - font-size: var(--b05-table-font, 11px); - line-height: 1.1; - user-select: none; -} - -/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ -.b05-profile-table__row { - position: relative; - flex: 1 1 0; - min-height: 12px; - border-bottom: 1px solid var(--color-border); - white-space: nowrap; -} - -/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ -.b05-profile-table__row.is-group-start { - border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); -} - -.b05-profile-table__row--grade { - background: color-mix(in srgb, var(--color-surface) 55%, transparent); -} - -.b05-profile-table__row:last-child { - border-bottom: 0; -} - -/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ -.b05-profile-table__label { - position: sticky; - z-index: 5; - left: 0; - display: inline-flex; - align-items: center; - /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 - (2026-08-05 사용자 지시). */ - justify-content: flex-end; - box-sizing: border-box; - width: var(--b05-table-label-width, 60px); - height: 100%; - padding-inline: var(--spacing-16) var(--spacing-4); - border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--b05-table-label-font, inherit); - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.b05-profile-table__cell, -.b05-profile-table__segment, -.b05-profile-table__curve { - position: absolute; - top: 0; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - overflow: hidden; -} - -/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 - 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ -.b05-profile-table__curve.is-rotated input { - writing-mode: vertical-rl; - width: 100%; - height: 100%; - padding: 0; - text-align: center; -} - -/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. - 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ -.b05-profile-table__cell, -.b05-profile-table__curve { - box-sizing: border-box; - width: var(--b05-table-cell-width, 56px); - border-left: 1px solid var(--color-border); - transform: translateX(-50%); -} - -.b05-profile-table__cell:last-child, -.b05-profile-table__curve:last-child { - border-right: 1px solid var(--color-border); -} - -/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 - 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 - 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ -.b05-profile-table__cell.is-structure { - border-left-color: transparent; -} - -.b05-profile-table__row.is-cut .b05-profile-table__cell { - color: rgb(220 38 38); -} - -.b05-profile-table__row.is-fill .b05-profile-table__cell { - color: rgb(37 99 235); -} - -.b05-profile-table__row.is-plan .b05-profile-table__cell { - color: var(--color-royal-amethyst, rgb(109 40 217)); - font-weight: var(--font-weight-medium); -} - -/* 구간 블록: 변화점 사이를 빈틈없이 채운다. - 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 - 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ -.b05-profile-table__segment { - box-sizing: border-box; - border-left: 1px solid var(--color-border); -} - -.b05-profile-table__segment:last-child { - border-right: 1px solid var(--color-border); -} - -.b05-profile-table__segment.is-violation { - background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); - color: rgb(180 83 9); -} - -/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ -.b05-profile-table__segment-value { - line-height: 1; - white-space: nowrap; -} - -.b05-profile-table__segment-value.is-rotated { - transform: rotate(-90deg); -} - -.b05-profile-table__curve { - z-index: 2; -} - -.b05-profile-table__curve.is-optional { - opacity: 0.75; -} - -.b05-profile-table__curve.is-omitted { - text-decoration: line-through; -} - -.b05-profile-table__radius { - box-sizing: border-box; - width: 100%; - height: 90%; - padding: 0 1px; - border: 1px solid var(--color-border); - border-radius: 2px; - background: var(--color-surface); - color: var(--color-text-body); - font-size: inherit; - text-align: center; -} - -/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ -.b05-profile-table__no-spin::-webkit-outer-spin-button, -.b05-profile-table__no-spin::-webkit-inner-spin-button { - margin: 0; - -webkit-appearance: none; - appearance: none; -} - -.b05-profile-table__no-spin { - -moz-appearance: textfield; - appearance: textfield; -} - -/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── - 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ -/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ - -.b05-profile-chart__context-menu { - position: absolute; - z-index: 7; - display: flex; - flex-direction: column; - min-width: 120px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-profile-chart__context-menu[hidden] { - display: none; -} - -.b05-profile-chart__context-menu-item { - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 12px; - text-align: left; - cursor: pointer; -} - -.b05-profile-chart__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. - 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ -.b05-profile-chart__context-menu, -.b05-drainage__context-menu { - max-height: 320px; - overflow-y: auto; -} - -.b05-profile-chart__context-menu-item.is-group, -.b05-drainage__context-menu-item.is-group { - font-weight: var(--font-weight-medium); -} - -.b05-profile-chart__context-menu-sub, -.b05-drainage__context-menu-sub { - display: flex; - flex-direction: column; -} - -.b05-profile-chart__context-menu-item.is-sub, -.b05-drainage__context-menu-item.is-sub { - padding-left: var(--spacing-24); -} - -.b05-profile-table__irregular-col { - position: absolute; - /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 - 행 제목이 가려지지 않는다. */ - z-index: 4; - top: 0; - bottom: 0; - display: flex; - flex-direction: column; - box-sizing: border-box; - border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 14%, - var(--color-surface) - ); - transform: translateX(-50%); - /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ - pointer-events: none; -} - -/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. - 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ -.b05-profile-table__irregular-col.is-floating { - border-inline-width: 2px; - box-shadow: - 0 0 0 1px var(--color-surface-raised), - 0 4px 12px rgb(0 0 0 / 28%); -} - -.b05-profile-table__irregular-col-cell { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-height: 0; - overflow: hidden; - border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); - color: var(--color-text); - font-variant-numeric: tabular-nums; - white-space: nowrap; -} - -/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ -.b05-profile-table__irregular-col-cell.is-split { - gap: 0; -} - -.b05-profile-table__irregular-col-half { - display: flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-width: 0; - height: 100%; - overflow: hidden; - font-size: 0.72em; -} - -.b05-profile-table__irregular-col-half:first-child { - border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); -} - -.b05-profile-table__irregular-col-cell:last-child { - border-bottom: 0; -} - -.b05-profile-table__irregular-col-cell.is-cut { - color: rgb(220 38 38); -} - -.b05-profile-table__irregular-col-cell.is-fill { - color: rgb(37 99 235); -} - -/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ - -/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ -.b05-profile-table__irregular-input { - box-sizing: border-box; - width: 92%; - height: 88%; - padding: 0; - /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 - (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ - border: 1px solid transparent; - border-radius: 2px; - background: transparent; - color: inherit; - font: inherit; - text-align: center; - pointer-events: auto; -} - -.b05-profile-table__irregular-input:focus, -.b05-profile-table__irregular-input:hover { - border-color: var(--color-royal-amethyst, rgb(109 40 217)); - background: var(--color-surface); -} - -/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ -/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 - (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ -.b05-profile-runmark { - position: absolute; - z-index: 3; - inset: 0; - pointer-events: none; -} - -.b05-profile-runmark__line { - fill: none; - stroke: var(--color-danger, #d64545); - stroke-width: 3; - stroke-linecap: round; - stroke-linejoin: round; - opacity: 0.85; -} - -/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ -.b05-profile-runmark__edge { - stroke: var(--color-danger, #d64545); - stroke-width: 1; - stroke-dasharray: 3 3; - opacity: 0.5; -} - -.b05-profile-edit { - position: absolute; - z-index: 4; - inset: 0; - pointer-events: none; -} - -/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 - 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ -.b05-profile-edit__btn { - position: absolute; - width: 21px; - height: 17px; - padding: 0; - font-size: 12px; - line-height: 1; - border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); - border-radius: 3px; - background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); - color: var(--color-text); - font-size: 9px; - line-height: 1; - cursor: pointer; - opacity: 0.9; - pointer-events: auto; - transition: - opacity var(--transition-fast), - background var(--transition-fast); -} - -.b05-route-profile .b05-profile-edit__btn:hover, -.b05-route-profile .b05-profile-edit__btn:focus-visible { - border-color: var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - opacity: 1; -} - -.b05-profile-edit__btn.is-up { - top: 2px; -} - -.b05-profile-edit__btn.is-down { - bottom: 2px; -} - -/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 - 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ - -/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 - 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / - 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. - is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ -.b05-profile-edit__btn.is-up { - border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); - color: var(--color-danger); -} - -/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ -.b05-profile-edit__btn.is-segment { - font-weight: 700; -} - -.b05-profile-edit__btn.is-down { - border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); - color: var(--color-chart-0, #5b8def); -} - -.b05-route-profile .b05-profile-edit__btn.is-up:hover, -.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { - border-color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); - color: var(--color-danger); -} - -.b05-route-profile .b05-profile-edit__btn.is-down:hover, -.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { - border-color: var(--color-chart-0, #5b8def); - background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); - color: var(--color-chart-0, #5b8def); -} - -.b05-profile-edit__btn.is-reset { - top: 22px; - border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - background: var(--color-surface-raised); - color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); - opacity: 0.95; -} - -/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ -.b05-drainage { - position: relative; - display: flex; - /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. - 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ - width: var(--b05-drainage-width, 38%); - min-width: 320px; - max-width: 70%; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - border-left: 1px solid var(--color-border); - background: var(--color-surface-raised); - transition: width var(--transition-fast); -} - -/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ -.b05-drainage.is-collapsed { - width: 0; - min-width: 0; -} - -.b05-drainage.is-collapsed .b05-drainage__header, -.b05-drainage.is-collapsed .b05-drainage__viewport { - display: none; -} - -/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. - 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ -.b05-drainage .ui-workflow-overlay__handle--side { - z-index: 6; - right: auto; - left: calc(-1 * var(--spacing-24)); - border-right: 0; - border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); -} - -.b05-drainage__header { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); - border-bottom: 1px solid var(--color-border); -} - -.b05-drainage__header h3 { - margin: 0; - color: var(--color-text); - font-size: var(--text-body-sm); -} - -/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 - 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. - 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 - 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ -.b05-drainage__map-overlay { - position: absolute; - z-index: 2; - top: var(--spacing-8); - left: var(--spacing-8); - display: flex; - flex-direction: column; - gap: var(--spacing-4); - /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ - pointer-events: none; -} - -/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 - 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 - 더 많이 가린다. */ -.b05-drainage__layers { - display: flex; - flex-wrap: nowrap; - gap: var(--spacing-4); - pointer-events: auto; -} - -/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. - 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ -.b05-drainage__layer-button { - /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ - flex: 0 0 auto; - white-space: nowrap; - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-caption); - opacity: 0.55; - cursor: pointer; -} - -/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ -.b05-drainage__layer-button.is-active { - border-color: var(--b05-layer-color, var(--color-border)); - box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); - color: var(--b05-layer-color, var(--color-text-body)); - opacity: 1; -} - -/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ -.b05-drainage__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 130px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-drainage__context-menu[hidden] { - display: none; -} - -.b05-drainage__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b05-drainage__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b05-drainage__viewport { - position: relative; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; - background: var(--color-surface); - /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 - grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - touch-action: none; -} - -.b05-drainage__image, -.b05-drainage__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b05-drainage__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b05-drainage__canvas { - pointer-events: none; -} - -/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ -.b05-drainage__status { - color: var(--color-text-secondary); - font-size: var(--text-caption); - pointer-events: none; -} - -.b05-drainage__analyze { - margin-left: auto; - padding: 2px var(--spacing-8); - border: 1px solid - color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); - border-radius: var(--radius-inputs); - background: var(--color-surface); - color: var(--color-text-body); - font-size: var(--text-caption); - cursor: pointer; -} - -.b05-drainage__analyze:disabled { - opacity: 0.45; - cursor: default; -} - -/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ -.b05-drainage__tool { - margin-left: var(--spacing-4, 4px); -} - -/* 배관 편집 토글 활성 상태. */ -.b05-drainage__analyze.is-active { - background: color-mix( - in srgb, - var(--color-royal-amethyst, rgb(109 40 217)) 18%, - var(--color-surface) - ); - border-color: var(--color-royal-amethyst, rgb(109 40 217)); -} - -/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ -/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). - 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ -.b05-drainage__basins { - --b05-basin-row: 34px; - - display: flex; - max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); - flex: 0 0 auto; - flex-direction: column; - gap: 2px; - overflow-y: auto; - padding: var(--spacing-8); - border-top: 1px solid var(--color-border); -} - -/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ -.b05-drainage__summary { - flex: 0 0 auto; - padding: var(--spacing-4) var(--spacing-8); - border-top: 1px solid var(--color-border); - color: var(--color-text-muted, var(--color-text-body)); - font-size: 12px; -} - -.b05-drainage__summary[hidden] { - display: none; -} - -.b05-drainage__basin { - display: flex; - min-height: var(--b05-basin-row); - align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid transparent; - border-radius: var(--radius-inputs); - background: none; - color: var(--color-text-body); - font-size: var(--text-caption); - text-align: left; - cursor: pointer; -} - -.b05-drainage__basin:hover, -.b05-drainage__basin.is-selected { - border-color: var(--color-border); - background: var(--color-surface); -} - -/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ -.b05-drainage__basin-index { - display: inline-flex; - width: 20px; - height: 20px; - flex: 0 0 auto; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: 50%; - color: #1f2937; - font-size: 11px; - font-weight: 600; -} - -.b05-drainage__basin-metrics { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ -.b05-profile-table__segment.is-structure { - opacity: 0.55; -} - -.b05-profile-table__segment.is-structure.is-highlight { - opacity: 1; - background: color-mix(in srgb, var(--color-primary) 14%, transparent); - outline: 1px solid var(--color-primary); -} - -/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── - * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 - * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 - * 여기서 같은 값으로 다시 적는다. */ -.b05-profile__zoom { - display: flex; - flex: 0 0 auto; - margin-left: auto; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); -} - -.b05-profile__zoom-btn { - width: 22px; - height: 22px; - padding: 0; - border: none; - border-left: 1px solid var(--color-border); - background: none; - color: var(--color-text-secondary); - font-size: 0.8rem; - line-height: 1; - cursor: pointer; -} - -.b05-profile__zoom-btn:first-child { - border-left: none; -} - -.b05-profile__zoom-btn:hover { - color: var(--color-text); - background: var(--color-surface); -} diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css new file mode 100644 index 00000000..cf5e4867 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -0,0 +1,289 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Drainage.css + * 하단 종단 패널 안쪽 **우측 배수유역도** 2단 사이드 패널. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼다. + * ========================================================================== */ + +/* ─── 배수유역도 패널 (하단 종단 패널 안쪽 우측 2단 사이드 패널) ──────────── */ +.b05-drainage { + position: relative; + display: flex; + /* 기본은 하단 패널의 38%. 왼쪽 경계를 끌면 --b05-drainage-width(px)가 대신 들어온다. + 상한 70%는 종단면도가 최소 30%는 남게 하는 안전판(JS 쪽 클램프와 같은 값). */ + width: var(--b05-drainage-width, 38%); + min-width: 320px; + max-width: 70%; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--color-border); + background: var(--color-surface-raised); + transition: width var(--transition-fast); +} + +/* 접으면 폭만 0으로 줄고, 좌측 가장자리 핸들은 남아 다시 펼 수 있다. */ +.b05-drainage.is-collapsed { + width: 0; + min-width: 0; +} + +.b05-drainage.is-collapsed .b05-drainage__header, +.b05-drainage.is-collapsed .b05-drainage__viewport { + display: none; +} + +/* 좌측 가장자리 세로 중앙 핸들 — 공용 side 핸들을 패널 왼쪽 밖으로 내보낸다. + 유토곡선 오버레이(z-index 5)가 그 위를 덮으면 안 보인다(2026-08-04 사용자 보고) — 위로. */ +.b05-drainage .ui-workflow-overlay__handle--side { + z-index: 6; + right: auto; + left: calc(-1 * var(--spacing-24)); + border-right: 0; + border-radius: var(--radius-buttons) 0 0 var(--radius-buttons); +} + +.b05-drainage__header { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) calc(var(--spacing-8) + var(--spacing-4)); + border-bottom: 1px solid var(--color-border); +} + +.b05-drainage__header h3 { + margin: 0; + color: var(--color-text); + font-size: var(--text-body-sm); +} + +/* 지도 좌상단 오버레이 — 표시 필터 버튼 줄 + 상태 문구(2026-08-17 사용자 지시로 + 헤더에서 옮겼다). 버튼 자체 여백과 별개로 지도 모서리에서 조금 띄운다. + 우측 폭을 묶는 이유: 화면 오른쪽 진행단계 오버레이(z-index 200)가 지도 위를 + 덮어, 여기까지 늘어난 버튼은 눌리지 않는다. 그 폭(패널 절반 + 우측 여백)을 뺀다. */ +.b05-drainage__map-overlay { + position: absolute; + z-index: 2; + top: var(--spacing-8); + left: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-4); + /* 오버레이 빈 자리로는 지도 조작(팬·유역 고르기)이 그대로 통해야 한다. */ + pointer-events: none; +} + +/* 필터 버튼은 한 줄로 둔다 — 오른쪽 진행단계 오버레이에 끝이 가려도 창을 넓히거나 + 그 오버레이를 접으면 드러난다(2026-08-17 사용자 확정). 두 줄로 접는 쪽이 지도를 + 더 많이 가린다. */ +.b05-drainage__layers { + display: flex; + flex-wrap: nowrap; + gap: var(--spacing-4); + pointer-events: auto; +} + +/* 표시 토글 버튼 — B04 지도 레이어 버튼(.b04-map__layer-button--gis)과 같은 양식을 쓴다. + 크기(패딩·글자)만 이 패널 기준을 유지한다(2026-08-01 사용자 지시). */ +.b05-drainage__layer-button { + /* 한 줄 유지 — 좁아져도 글자가 접히거나 쭈그러들지 않게 크기를 고정한다. */ + flex: 0 0 auto; + white-space: nowrap; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.55; + cursor: pointer; +} + +/* 켜진 레이어는 그 레이어의 선 색을 테두리·글자·안쪽 링에 그대로 쓴다(지도와 바로 대조). */ +.b05-drainage__layer-button.is-active { + border-color: var(--b05-layer-color, var(--color-border)); + box-shadow: inset 0 0 0 1px var(--b05-layer-color, transparent); + color: var(--b05-layer-color, var(--color-text-body)); + opacity: 1; +} + +/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ +.b05-drainage__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 130px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-drainage__context-menu[hidden] { + display: none; +} + +.b05-drainage__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b05-drainage__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b05-drainage__viewport { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: var(--color-surface); + /* 좌버튼은 유역선·배관 마커를 고르는 데만 쓴다. 팬 중(가운데 버튼)에만 JS가 + grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + touch-action: none; +} + +.b05-drainage__image, +.b05-drainage__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b05-drainage__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b05-drainage__canvas { + pointer-events: none; +} + +/* 상태 문구는 좌상단 오버레이가 세로로 쌓아 준다 — 필터 버튼 줄 바로 아래. */ +.b05-drainage__status { + color: var(--color-text-secondary); + font-size: var(--text-caption); + pointer-events: none; +} + +.b05-drainage__analyze { + margin-left: auto; + padding: 2px var(--spacing-8); + border: 1px solid + color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); + cursor: pointer; +} + +.b05-drainage__analyze:disabled { + opacity: 0.45; + cursor: default; +} + +/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */ +.b05-drainage__tool { + margin-left: var(--spacing-4, 4px); +} + +/* 배관 편집 토글 활성 상태. */ +.b05-drainage__analyze.is-active { + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 18%, + var(--color-surface) + ); + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + +/* 유역 제원 목록 — 면적·유역표고·유하거리·관경(수식 확정 전까지 "미정"). */ +/* 유역 목록은 3행까지만 보이고 나머지는 스크롤한다(2026-08-01 사용자 지시). + 높이를 비율로 잡으면 유역 수와 무관하게 잘려 몇 개인지 가늠이 안 된다. */ +.b05-drainage__basins { + --b05-basin-row: 34px; + + display: flex; + max-height: calc(3 * var(--b05-basin-row) + 2 * 2px + 2 * var(--spacing-8)); + flex: 0 0 auto; + flex-direction: column; + gap: 2px; + overflow-y: auto; + padding: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +/* 관 개수·세부유역 수·종단 Z 출처 한 줄. */ +.b05-drainage__summary { + flex: 0 0 auto; + padding: var(--spacing-4) var(--spacing-8); + border-top: 1px solid var(--color-border); + color: var(--color-text-muted, var(--color-text-body)); + font-size: 12px; +} + +.b05-drainage__summary[hidden] { + display: none; +} + +.b05-drainage__basin { + display: flex; + min-height: var(--b05-basin-row); + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-inputs); + background: none; + color: var(--color-text-body); + font-size: var(--text-caption); + text-align: left; + cursor: pointer; +} + +.b05-drainage__basin:hover, +.b05-drainage__basin.is-selected { + border-color: var(--color-border); + background: var(--color-surface); +} + +/* 지도 위 서클 번호와 같은 파스텔 색을 써서 목록 항목과 유역을 눈으로 잇는다. */ +.b05-drainage__basin-index { + display: inline-flex; + width: 20px; + height: 20px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 50%; + color: #1f2937; + font-size: 11px; + font-weight: 600; +} + +.b05-drainage__basin-metrics { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css new file mode 100644 index 00000000..85044eca --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -0,0 +1,515 @@ +/* ============================================================================= + * B05_Profile_UI_Style_Table.css + * 종단면도 하단 **도면 테이블**과 그 위에 얹히는 것들 — + * 표(구배~곡선) · 선택 측점 값 열 오버레이 · 그래프 우클릭 메뉴 · + * 계획고 편집 버튼 · 줌/Y레인지 조작구. + * + * `B05_Profile_UI_Style.css` 가 700줄을 넘겨 **잘라낸 조각**이다(2026-09-04). + * 규칙은 원본 순서 그대로 옮겼고 값은 하나도 바꾸지 않았다 — 진입 TS 가 + * 본체 다음에 이 파일을 불러오므로 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* ─── 도면 테이블 (구배 ~ 곡선 9행) ─────────────────────────────────────── + 셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다. + 행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */ +.b05-profile-table { + position: relative; + display: flex; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + color: var(--color-text-body); + /* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */ + font-size: var(--b05-table-font, 11px); + line-height: 1.1; + user-select: none; +} + +/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */ +.b05-profile-table__row { + position: relative; + flex: 1 1 0; + min-height: 12px; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */ +.b05-profile-table__row.is-group-start { + border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet)); +} + +.b05-profile-table__row--grade { + background: color-mix(in srgb, var(--color-surface) 55%, transparent); +} + +.b05-profile-table__row:last-child { + border-bottom: 0; +} + +/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */ +.b05-profile-table__label { + position: sticky; + z-index: 5; + left: 0; + display: inline-flex; + align-items: center; + /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 + (2026-08-05 사용자 지시). */ + justify-content: flex-end; + box-sizing: border-box; + width: var(--b05-table-label-width, 60px); + height: 100%; + padding-inline: var(--spacing-16) var(--spacing-4); + border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: var(--b05-table-label-font, inherit); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.b05-profile-table__cell, +.b05-profile-table__segment, +.b05-profile-table__curve { + position: absolute; + top: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + overflow: hidden; +} + +/* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 + 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ +.b05-profile-table__curve.is-rotated input { + writing-mode: vertical-rl; + width: 100%; + height: 100%; + padding: 0; + text-align: center; +} + +/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. + 셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */ +.b05-profile-table__cell, +.b05-profile-table__curve { + box-sizing: border-box; + width: var(--b05-table-cell-width, 56px); + border-left: 1px solid var(--color-border); + transform: translateX(-50%); +} + +.b05-profile-table__cell:last-child, +.b05-profile-table__curve:last-child { + border-right: 1px solid var(--color-border); +} + +/* 구조물 측점 빈 칸의 세로선은 이웃 규칙 측점의 값 한가운데를 지나고, 곡선 L/R 칸 + 경계와 x가 겹쳐 곡선 행 구분선이 위 행들까지 이어진 것처럼 보인다. 곡선 행에만 + 남기고 값 행에서는 지운다(2026-08-30 사용자 지시). */ +.b05-profile-table__cell.is-structure { + border-left-color: transparent; +} + +.b05-profile-table__row.is-cut .b05-profile-table__cell { + color: rgb(220 38 38); +} + +.b05-profile-table__row.is-fill .b05-profile-table__cell { + color: rgb(37 99 235); +} + +.b05-profile-table__row.is-plan .b05-profile-table__cell { + color: var(--color-royal-amethyst, rgb(109 40 217)); + font-weight: var(--font-weight-medium); +} + +/* 구간 블록: 변화점 사이를 빈틈없이 채운다. + 구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면 + 충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */ +.b05-profile-table__segment { + box-sizing: border-box; + border-left: 1px solid var(--color-border); +} + +.b05-profile-table__segment:last-child { + border-right: 1px solid var(--color-border); +} + +.b05-profile-table__segment.is-violation { + background: color-mix(in srgb, rgb(220 38 38) 12%, transparent); + color: rgb(180 83 9); +} + +/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ +.b05-profile-table__segment-value { + line-height: 1; + white-space: nowrap; +} + +.b05-profile-table__segment-value.is-rotated { + transform: rotate(-90deg); +} + +.b05-profile-table__curve { + z-index: 2; +} + +.b05-profile-table__curve.is-optional { + opacity: 0.75; +} + +.b05-profile-table__curve.is-omitted { + text-decoration: line-through; +} + +.b05-profile-table__radius { + box-sizing: border-box; + width: 100%; + height: 90%; + padding: 0 1px; + border: 1px solid var(--color-border); + border-radius: 2px; + background: var(--color-surface); + color: var(--color-text-body); + font-size: inherit; + text-align: center; +} + +/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */ +.b05-profile-table__no-spin::-webkit-outer-spin-button, +.b05-profile-table__no-spin::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.b05-profile-table__no-spin { + -moz-appearance: textfield; + appearance: textfield; +} + +/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── + 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ +/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ + +.b05-profile-chart__context-menu { + position: absolute; + z-index: 7; + display: flex; + flex-direction: column; + min-width: 120px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b05-profile-chart__context-menu[hidden] { + display: none; +} + +.b05-profile-chart__context-menu-item { + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.b05-profile-chart__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. + 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ +.b05-profile-chart__context-menu, +.b05-drainage__context-menu { + max-height: 320px; + overflow-y: auto; +} + +.b05-profile-chart__context-menu-item.is-group, +.b05-drainage__context-menu-item.is-group { + font-weight: var(--font-weight-medium); +} + +.b05-profile-chart__context-menu-sub, +.b05-drainage__context-menu-sub { + display: flex; + flex-direction: column; +} + +.b05-profile-chart__context-menu-item.is-sub, +.b05-drainage__context-menu-item.is-sub { + padding-left: var(--spacing-24); +} + +.b05-profile-table__irregular-col { + position: absolute; + /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 + 행 제목이 가려지지 않는다. */ + z-index: 4; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + box-sizing: border-box; + border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217)); + background: color-mix( + in srgb, + var(--color-royal-amethyst, rgb(109 40 217)) 14%, + var(--color-surface) + ); + transform: translateX(-50%); + /* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */ + pointer-events: none; +} + +/* 비정규(구조물) 측점은 측점 격자와 무관한 위치에 떠서, 이웃 셀을 가린 자리에 뜬다. + 떠 있는 창임이 드러나게 그림자·굵은 테두리로 구분한다(규칙 측점은 격자와 일치해 불필요). */ +.b05-profile-table__irregular-col.is-floating { + border-inline-width: 2px; + box-shadow: + 0 0 0 1px var(--color-surface-raised), + 0 4px 12px rgb(0 0 0 / 28%); +} + +.b05-profile-table__irregular-col-cell { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); + color: var(--color-text); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ +.b05-profile-table__irregular-col-cell.is-split { + gap: 0; +} + +.b05-profile-table__irregular-col-half { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-width: 0; + height: 100%; + overflow: hidden; + font-size: 0.72em; +} + +.b05-profile-table__irregular-col-half:first-child { + border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); +} + +.b05-profile-table__irregular-col-cell:last-child { + border-bottom: 0; +} + +.b05-profile-table__irregular-col-cell.is-cut { + color: rgb(220 38 38); +} + +.b05-profile-table__irregular-col-cell.is-fill { + color: rgb(37 99 235); +} + +/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */ + +/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */ +.b05-profile-table__irregular-input { + box-sizing: border-box; + width: 92%; + height: 88%; + padding: 0; + /* 계획고만 테두리·색으로 따로 강조하지 않는다 — 같은 열의 다른 값과 같아 보여야 한다 + (2026-08-02 사용자 지시). 고칠 수 있다는 표시는 커서와 포커스 테두리로 충분하다. */ + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: inherit; + font: inherit; + text-align: center; + pointer-events: auto; +} + +.b05-profile-table__irregular-input:focus, +.b05-profile-table__irregular-input:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); + background: var(--color-surface); +} + +/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */ +/* [쉬프트]로 고른 직선 구간 강조 — 직선과 **양 끝 라운드**를 함께 빨갛게 덧그린다 + (2026-09-03 사용자 지시). 편집 버튼층(z-index 4)보다 아래에 깔아 버튼을 가리지 않는다. */ +.b05-profile-runmark { + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; +} + +.b05-profile-runmark__line { + fill: none; + stroke: var(--color-danger, #d64545); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +/* 구간 경계(라운드 시·종점) 세로 표식 — 어디까지 함께 움직이는지 알린다. */ +.b05-profile-runmark__edge { + stroke: var(--color-danger, #d64545); + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.5; +} + +.b05-profile-edit { + position: absolute; + z-index: 4; + inset: 0; + pointer-events: none; +} + +/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기는 ▲▼·↺·⬆⬇ 전부 21×17로 + 키웠다(2026-08-04 사용자 지시 — 18×15는 누르기도 읽기도 작았다). */ +.b05-profile-edit__btn { + position: absolute; + width: 21px; + height: 17px; + padding: 0; + font-size: 12px; + line-height: 1; + border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + border-radius: 3px; + background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent); + color: var(--color-text); + font-size: 9px; + line-height: 1; + cursor: pointer; + opacity: 0.9; + pointer-events: auto; + transition: + opacity var(--transition-fast), + background var(--transition-fast); +} + +.b05-route-profile .b05-profile-edit__btn:hover, +.b05-route-profile .b05-profile-edit__btn:focus-visible { + border-color: var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text); + opacity: 1; +} + +.b05-profile-edit__btn.is-up { + top: 2px; +} + +.b05-profile-edit__btn.is-down { + bottom: 2px; +} + +/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만 + 렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */ + +/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이 + 배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b / + 다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다. + is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */ +.b05-profile-edit__btn.is-up { + border-color: color-mix(in srgb, var(--color-danger) 65%, transparent); + color: var(--color-danger); +} + +/* 구간 이동(⬆⬇)은 속 찬 화살표가 잘 읽히도록 굵게(크기는 공통 21×17). */ +.b05-profile-edit__btn.is-segment { + font-weight: 700; +} + +.b05-profile-edit__btn.is-down { + border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent); + color: var(--color-chart-0, #5b8def); +} + +.b05-route-profile .b05-profile-edit__btn.is-up:hover, +.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised)); + color: var(--color-danger); +} + +.b05-route-profile .b05-profile-edit__btn.is-down:hover, +.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible { + border-color: var(--color-chart-0, #5b8def); + background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised)); + color: var(--color-chart-0, #5b8def); +} + +.b05-profile-edit__btn.is-reset { + top: 22px; + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + background: var(--color-surface-raised); + color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text)); + opacity: 0.95; +} +.b05-profile-table__segment.is-structure { + opacity: 0.55; +} + +.b05-profile-table__segment.is-structure.is-highlight { + opacity: 1; + background: color-mix(in srgb, var(--color-primary) 14%, transparent); + outline: 1px solid var(--color-primary); +} + +/* ── 종단도 줌·Y레인지 조작구 (2026-09-04) ───────────────────────────── + * 요약줄 오른쪽 끝(= 그래프 우측 상단)에 붙는다. 양식은 횡단도 줌 버튼세트 + * (.b06-cross-card__zoom-btn)와 같게 맞췄다 — 그쪽 CSS는 B06 페이지 전용이라 + * 여기서 같은 값으로 다시 적는다. */ +.b05-profile__zoom { + display: flex; + flex: 0 0 auto; + margin-left: auto; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); +} + +.b05-profile__zoom-btn { + width: 22px; + height: 22px; + padding: 0; + border: none; + border-left: 1px solid var(--color-border); + background: none; + color: var(--color-text-secondary); + font-size: 0.8rem; + line-height: 1; + cursor: pointer; +} + +.b05-profile__zoom-btn:first-child { + border-left: none; +} + +.b05-profile__zoom-btn:hover { + color: var(--color-text); + background: var(--color-surface); +} -- 2.54.0 From 2975ea09c7f929988df1a326b5f5875fd20ed164 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:09:10 +0900 Subject: [PATCH 068/441] =?UTF-8?q?refactor(B04):=20=EC=A0=84=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=8A=A4=ED=83=80=EC=9D=BC=EC=8B=9C=ED=8A=B8=20700?= =?UTF-8?q?=EC=A4=84=20=EB=B6=84=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04_PreProcess_UI_Style.css 870줄 -> 565줄. 하단 2D 지도·도구줄·시트 툴바 규칙을 B04_PreProcess_UI_Style_Map.css(312줄)로 값·순서 그대로 옮김. 진입 TS 에서 본체 다음에 불러 캐스케이드 순서 유지. 검증: 두 파일을 다시 이으면 원본과 **경계 빈 줄 1개 외 완전 동일**(텍스트 대조). tsc --noEmit 통과. 공용 브라우저(5174) B04 화면 실측 — 좌측 폼 flex/gap 16px, .b04-map flex column gap 12px(896x828), 헤더·컨트롤 정상, 3D 서피스·2D 지도 렌더 정상. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_UI_Page.ts | 2 + B04_PreProcess/B04_PreProcess_UI_Style.css | 305 ----------------- .../B04_PreProcess_UI_Style_Map.css | 312 ++++++++++++++++++ 3 files changed, 314 insertions(+), 305 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_UI_Style_Map.css diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index 300c8495..6b97dd5e 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -39,6 +39,8 @@ import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer"; import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer"; import { createSurfacePointCloudViewer } from "./B04_PreProcess_UI_Viewer"; import "./B04_PreProcess_UI_Style.css"; +// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04). +import "./B04_PreProcess_UI_Style_Map.css"; // 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가 // 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정). diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 62e63d40..3ac47607 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -563,308 +563,3 @@ right: var(--spacing-16); bottom: var(--spacing-16); } - -/* --- 하단 2D 지도 --- */ -.b04-map { - --b04-map-vector: var(--color-accent); - display: flex; - flex-direction: column; - gap: var(--spacing-12); - margin: var(--spacing-24); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); -} - -.b04-map__header, -.b04-map__controls, -.b04-map__control-group, -.b04-map__layer-buttons { - display: flex; - align-items: center; -} - -.b04-map__header { - justify-content: space-between; - gap: var(--spacing-16); -} - -.b04-map__header h3 { - font-size: var(--text-body); - color: var(--color-text); -} - -.b04-map__controls { - gap: var(--spacing-12); - flex-wrap: wrap; -} - -.b04-map__control-group { - gap: var(--spacing-8); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -.b04-map__layer-buttons { - gap: var(--spacing-4); - flex-wrap: wrap; -} - -.b04-map__controls button { - min-height: 34px; - padding: 0 var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: var(--color-canvas); - color: var(--color-text-body); -} - -.b04-map__controls button { - cursor: pointer; -} - -.b04-map__controls .b04-map__layer-button { - padding: var(--spacing-8); - opacity: 0.55; -} - -.b04-map__layer-button.is-active { - opacity: 1; - box-shadow: inset 0 0 0 1px currentColor; -} - -.b04-map__layer-button--gis { - border-color: var(--color-border); - color: var(--color-text-secondary); -} - -.b04-map__layer-button--gis.is-active { - border-color: var(--b04-layer-color); - color: var(--b04-layer-color); - box-shadow: inset 0 0 0 1px var(--b04-layer-color); -} - -/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ -.b04-map__context-menu { - position: absolute; - z-index: 6; - display: flex; - flex-direction: column; - min-width: 140px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b04-map__context-menu[hidden] { - display: none; -} - -.b04-map__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b04-map__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -.b04-map__viewport { - position: relative; - width: 100%; - height: 560px; - overflow: hidden; - touch-action: none; - /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 - JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ - cursor: default; - user-select: none; - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-canvas); -} - -.b04-map__image, -.b04-map__canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.b04-map__image { - object-fit: contain; - transform-origin: center; - user-select: none; - pointer-events: none; -} - -.b04-map__canvas { - pointer-events: none; -} - -.b04-map__empty { - position: absolute; - z-index: 2; - inset: 50% auto auto 50%; - transform: translate(-50%, -50%); - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - -/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). - 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ -.b04-map__status-stack { - position: absolute; - z-index: 2; - top: var(--spacing-12); - left: var(--spacing-12); - display: flex; - max-width: min(62%, 900px); - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). - 결과 문구는 좌상단에 그대로 남는다. */ -.b04-map__status-topright { - position: absolute; - z-index: 2; - top: var(--spacing-12); - right: var(--spacing-12); - display: flex; - max-width: min(40%, 360px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). - 축척은 좌하단이라 서로 겹치지 않는다. */ -.b04-map__status-corner { - position: absolute; - z-index: 2; - right: var(--spacing-12); - bottom: var(--spacing-12); - display: flex; - max-width: min(50%, 480px); - flex-direction: column; - align-items: flex-end; - gap: var(--spacing-4); - pointer-events: none; -} - -/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 - 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. - 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ -.b04-map__status, -.b04-map__watershed-status { - display: block; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); - color: var(--color-text-body); - font-size: var(--text-caption); - line-height: 1.5; - word-break: keep-all; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} - -.b04-map__watershed-status[hidden] { - display: none; -} - -@media (max-width: 760px) { - .b04-map__header { - align-items: flex-start; - flex-direction: column; - } -} - -@media (max-width: 1180px) { - .b04-surface__viewers { - grid-template-columns: 1fr; - } -} - -/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ -.b04-surface__sheet-section { - margin: 0 var(--spacing-24) var(--spacing-16); - padding: var(--spacing-16); - box-sizing: border-box; -} - -.b04-surface__sheet-section > .terrain-model-group { - margin-top: var(--spacing-12); -} - -/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ -.b04-surface__sheet-toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--spacing-8); - margin: var(--spacing-12) 0; -} - -.b04-surface__sheet-method { - padding: 4px 10px; - font-size: var(--text-caption); - color: var(--color-text-body); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - cursor: pointer; -} - -.b04-surface__sheet-method:hover { - border-color: var(--color-primary); -} - -.b04-surface__sheet-method.is-active { - color: var(--color-on-primary, #fff); - background: var(--color-primary); - border-color: var(--color-primary); -} - -/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ -.b04-surface__sheet-method.is-unbuilt:not(.is-active) { - color: var(--color-text-muted, var(--color-text-body)); - border-style: dashed; -} - -.b04-surface__sheet-lidar { - margin-left: auto; -} - -/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ -.b04-surface__sheet-smoothing { - margin-left: auto; - display: flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-caption); -} - -.b04-surface__sheet-smoothing .b04-surface__select { - width: auto; - min-width: 96px; -} - -.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { - margin-left: 0; -} diff --git a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css new file mode 100644 index 00000000..b2f0bc28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -0,0 +1,312 @@ +/* ============================================================================= + * B04_PreProcess_UI_Style_Map.css + * 1차 워크플로우 하단 **2D 지도**와 그 도구줄·시트 툴바. + * + * `B04_PreProcess_UI_Style.css` 가 700줄을 넘겨 잘라낸 조각이다(2026-09-04). + * 규칙·순서·값 그대로 옮겼고, 진입 TS 가 본체 다음에 불러 캐스케이드 순서도 같다. + * ========================================================================== */ + +/* --- 하단 2D 지도 --- */ +.b04-map { + --b04-map-vector: var(--color-accent); + display: flex; + flex-direction: column; + gap: var(--spacing-12); + margin: var(--spacing-24); + padding: var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b04-map__header, +.b04-map__controls, +.b04-map__control-group, +.b04-map__layer-buttons { + display: flex; + align-items: center; +} + +.b04-map__header { + justify-content: space-between; + gap: var(--spacing-16); +} + +.b04-map__header h3 { + font-size: var(--text-body); + color: var(--color-text); +} + +.b04-map__controls { + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.b04-map__control-group { + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b04-map__layer-buttons { + gap: var(--spacing-4); + flex-wrap: wrap; +} + +.b04-map__controls button { + min-height: 34px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-canvas); + color: var(--color-text-body); +} + +.b04-map__controls button { + cursor: pointer; +} + +.b04-map__controls .b04-map__layer-button { + padding: var(--spacing-8); + opacity: 0.55; +} + +.b04-map__layer-button.is-active { + opacity: 1; + box-shadow: inset 0 0 0 1px currentColor; +} + +.b04-map__layer-button--gis { + border-color: var(--color-border); + color: var(--color-text-secondary); +} + +.b04-map__layer-button--gis.is-active { + border-color: var(--b04-layer-color); + color: var(--b04-layer-color); + box-shadow: inset 0 0 0 1px var(--b04-layer-color); +} + +/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ +.b04-map__context-menu { + position: absolute; + z-index: 6; + display: flex; + flex-direction: column; + min-width: 140px; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: 0 4px 16px rgb(0 0 0 / 25%); +} + +.b04-map__context-menu[hidden] { + display: none; +} + +.b04-map__context-menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-cards); + background: transparent; + color: var(--color-text-body); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.b04-map__context-menu-item:hover { + background: var(--color-surface-sunken); +} + +.b04-map__viewport { + position: relative; + width: 100%; + height: 560px; + overflow: hidden; + touch-action: none; + /* 좌버튼은 선택 전용이라 손바닥(grab) 커서를 쓰지 않는다. 팬 중(가운데 버튼)에만 + JS가 grabbing으로 바꾼다(2026-08-01 사용자 지시). */ + cursor: default; + user-select: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-canvas); +} + +.b04-map__image, +.b04-map__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.b04-map__image { + object-fit: contain; + transform-origin: center; + user-select: none; + pointer-events: none; +} + +.b04-map__canvas { + pointer-events: none; +} + +.b04-map__empty { + position: absolute; + z-index: 2; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 지도 위 좌상단 문구 묶음 — 배수유역 정보 자리(2026-08-01 사용자 지시). + 지도 조작을 가리지 않도록 포인터 이벤트는 통과시킨다. */ +.b04-map__status-stack { + position: absolute; + z-index: 2; + top: var(--spacing-12); + left: var(--spacing-12); + display: flex; + max-width: min(62%, 900px); + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 "분석 중…" 진행 문구 자리 — 우측 최상단(2026-08-01 사용자 지시). + 결과 문구는 좌상단에 그대로 남는다. */ +.b04-map__status-topright { + position: absolute; + z-index: 2; + top: var(--spacing-12); + right: var(--spacing-12); + display: flex; + max-width: min(40%, 360px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 객체 표시(지형지물 개수) 라벨 자리 — 우측 최하단(2026-08-01 사용자 지시). + 축척은 좌하단이라 서로 겹치지 않는다. */ +.b04-map__status-corner { + position: absolute; + z-index: 2; + right: var(--spacing-12); + bottom: var(--spacing-12); + display: flex; + max-width: min(50%, 480px); + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-4); + pointer-events: none; +} + +/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면 + 나중에 끝난 쪽이 상대 문구를 지운다. 그래서 줄을 따로 둔다. + 배경지도(위성·지적·등고선)가 복잡해 글자가 묻히므로 배경 칩을 깐다. */ +.b04-map__status, +.b04-map__watershed-status { + display: block; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 92%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.5; + word-break: keep-all; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +.b04-map__watershed-status[hidden] { + display: none; +} + +@media (max-width: 760px) { + .b04-map__header { + align-items: flex-start; + flex-direction: column; + } +} + +@media (max-width: 1180px) { + .b04-surface__viewers { + grid-template-columns: 1fr; + } +} + +/* 도엽등고 3D 서피스 컨테이너 — 2026-08-30 */ +.b04-surface__sheet-section { + margin: 0 var(--spacing-24) var(--spacing-16); + padding: var(--spacing-16); + box-sizing: border-box; +} + +.b04-surface__sheet-section > .terrain-model-group { + margin-top: var(--spacing-12); +} + +/* 도엽 서피스 보간 방식 전환 줄 — 2026-08-30 */ +.b04-surface__sheet-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: var(--spacing-12) 0; +} + +.b04-surface__sheet-method { + padding: 4px 10px; + font-size: var(--text-caption); + color: var(--color-text-body); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + cursor: pointer; +} + +.b04-surface__sheet-method:hover { + border-color: var(--color-primary); +} + +.b04-surface__sheet-method.is-active { + color: var(--color-on-primary, #fff); + background: var(--color-primary); + border-color: var(--color-primary); +} + +/* 아직 만들지 않은 방식 — 누르면 계산이 시작된다는 것을 눌러 보기 전에 알린다. */ +.b04-surface__sheet-method.is-unbuilt:not(.is-active) { + color: var(--color-text-muted, var(--color-text-body)); + border-style: dashed; +} + +.b04-surface__sheet-lidar { + margin-left: auto; +} + +/* 도엽 서피스 스무딩 드롭다운 — 방식 버튼 줄 오른쪽 끝 */ +.b04-surface__sheet-smoothing { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); +} + +.b04-surface__sheet-smoothing .b04-surface__select { + width: auto; + min-width: 96px; +} + +.b04-surface__sheet-toolbar .b04-surface__sheet-lidar { + margin-left: 0; +} -- 2.54.0 From 03a193af706658a5e61e2584993d3b553230869e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:13:36 +0900 Subject: [PATCH 069/441] =?UTF-8?q?refactor(B04):=20=EC=A7=80=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20700=EC=A4=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(=EA=B2=BD=EB=A1=9C=C2=B7=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04_PreProcess_Router.py 718줄 -> 493줄. - B04_PreProcess_Router_Status.py(221줄) — 분석 상태 조회·모델 프리뷰 엔드포인트. prefix 없이 만들고 본체가 include_router 로 실어 붙임(본체 prefix 를 그대로 상속). - B04_PreProcess_Router_Progress.py(47줄) — progress.json 읽기·쓰기 헬퍼. 본체와 상태 라우터가 함께 써야 해 순환 임포트를 피하려고 별도 모듈로 둠. 본체는 write_surface_progress 를 계속 재노출(B03_FileInput_Service_WF1 이 참조). 검증: ruff check/format 통과. 라우터 경로 9개가 분리 전과 동일(중복 prefix 없음 확인). 백엔드 재시작(8001) 후 openapi 에 surface 경로 12개 그대로, 브라우저에서 /surface/status 200(진행률 100·모델 3), /surface/models 200, /surface/models/415/preview 200 model/gltf-binary 7,946,756바이트. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_Router.py | 243 +----------------- .../B04_PreProcess_Router_Progress.py | 47 ++++ .../B04_PreProcess_Router_Status.py | 223 ++++++++++++++++ 3 files changed, 279 insertions(+), 234 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_Router_Progress.py create mode 100644 B04_PreProcess/B04_PreProcess_Router_Status.py diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 8615af91..c4e6ad6a 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -7,10 +7,9 @@ from pathlib import Path from typing import Any from uuid import UUID -import aiomysql import numpy as np -from fastapi import APIRouter, Depends, Request -from fastapi.responses import JSONResponse, Response +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine import ( @@ -30,6 +29,10 @@ from B04_PreProcess.B04_PreProcess_Repository import ( list_surface_models, save_surface_analysis_to_db, ) +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + write_surface_progress, +) +from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router from B04_PreProcess.B04_PreProcess_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, @@ -45,9 +48,6 @@ from B04_PreProcess.B04_PreProcess_Schema import ( ) from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection from common_util.common_util_auth import require_system_admin -from common_util.common_util_http_cache import cached_file_response -from common_util.common_util_initial_snapshot import is_designing -from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import ( get_surface_confirmation_params, @@ -63,37 +63,10 @@ from config.config_db import get_db_pool logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"]) POINT_CLOUD_SAMPLE_LIMIT = 500_000 -# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. -PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") - -def _progress_file_path(project_root: Path) -> Path: - return project_root.joinpath(*PROGRESS_FILE_RELATIVE) - - -def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: - """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" - try: - path = _progress_file_path(project_root) - path.parent.mkdir(parents=True, exist_ok=True) - atomic_write_json( - path, - {"progress_percent": percent, "current_stage": stage, "message": message}, - ) - except OSError: - logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) - - -def read_surface_progress(project_root: Path) -> dict[str, Any] | None: - """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" - path = _progress_file_path(project_root) - if not path.is_file(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return data if isinstance(data, dict) else None - except (OSError, ValueError): - return None +# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내 +# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04). +router.include_router(status_router) @router.post("/{project_id}/surface/analyze", response_model=SurfaceAnalyzeResponse) @@ -518,201 +491,3 @@ async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsRespon status_code=500, content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."}, ) - - -@router.get("/{project_id}/surface/status") -async def get_wf1_analysis_status(project_id: UUID) -> dict: - """WF1 분석 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT state, progress_percent, message, - (SELECT COUNT(*) FROM surface_models - WHERE project_id = %s) as model_count - FROM project_workflow_stages - WHERE project_id = %s AND stage_no = 1 - """, - (str(project_id), str(project_id)), - ) - row = await cursor.fetchone() - - # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) - if not row: - await cursor.execute( - """ - SELECT p.status as project_status, COUNT(sm.id) as model_count - FROM projects p - LEFT JOIN surface_models sm ON sm.project_id = p.id - WHERE p.id = %s AND p.deleted_at IS NULL - GROUP BY p.id, p.status - """, - (str(project_id),), - ) - fallback_row = await cursor.fetchone() - if not fallback_row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, - ) - model_count = int(fallback_row["model_count"]) - project_status = str(fallback_row.get("project_status") or "NEW") - - if project_status == "WF1_FAILED": - state = "FAILED" - progress_percent = 0 - message = "WF1 분석에 실패했습니다." - elif model_count > 0 or project_status == "WF1_COMPLETE": - state = "COMPLETE" - progress_percent = 100 - message = "WF1 분석이 완료되었습니다." - elif project_status == "WF1_ANALYZING": - state = "IN_PROGRESS" - progress_percent = 30 - message = "WF1 분석이 진행 중입니다." - else: - state = "NOT_STARTED" - progress_percent = 0 - message = "WF1 분석 대기 중입니다." - else: - state = row["state"] - progress_percent = row["progress_percent"] - message = row["message"] or "" - model_count = int(row["model_count"]) - - if state == "FAILED": - status = "failed" - current_stage = "failed" - if not message: - message = "WF1 분석에 실패했습니다." - elif state == "COMPLETE": - status = "completed" - progress_percent = 100 - current_stage = "completed" - if not message: - message = "WF1 분석이 완료되었습니다." - elif state == "IN_PROGRESS": - status = "in_progress" - current_stage = "surface_analysis" - if not message: - message = "WF1 분석이 진행 중입니다." - # 진행률 파일이 있으면 실제 단계별 진행률로 대체 - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) - if progress: - progress_percent = int(progress.get("progress_percent", progress_percent)) - current_stage = str(progress.get("current_stage", current_stage)) - message = str(progress.get("message", message)) - except LookupError: - pass - else: - status = "pending" - progress_percent = 0 - current_stage = "pending" - if not message: - message = "WF1 분석 대기 중입니다." - - # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 - # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). - # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): - status = "in_progress" - current_stage = "initial_design" - message = "초기 설계를 계산하는 중입니다." - except (LookupError, OSError, ValueError): - pass - - return { - "project_id": str(project_id), - "status": status, - "model_count": model_count, - "progress_percent": progress_percent, - "current_stage": current_stage, - "message": message, - } - except Exception: - logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) -async def get_surface_model_preview( - request: Request, - project_id: UUID, - model_id: int, - smooth: bool = False, -) -> Response | JSONResponse: - """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor() as cursor: - await cursor.execute( - """ - SELECT model_type, model_file_path - FROM surface_models - WHERE id = %s AND project_id = %s - """, - (model_id, str(project_id)), - ) - row = await cursor.fetchone() - if not row: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, - ) - model_type, model_file_path = row[0], row[1] - project_root = Path(resolve_stored_project_path(stored_path)) - if not model_file_path: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "모델 파일 경로가 없습니다."}, - ) - model_path = project_root / model_file_path - models_dir = model_path.parent - stem = model_path.stem - - ext = "ply" if model_type == "meshfree" else "glb" - if smooth and model_type in ("dtm", "tin"): - preview_filename = f"{stem}_smooth_preview.glb" - else: - preview_filename = f"{stem}_preview.{ext}" - - preview_path = models_dir / preview_filename - if not preview_path.is_file(): - return JSONResponse( - status_code=404, - content={ - "status": "error", - "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", - }, - ) - - media_type = "application/octet-stream" - if ext == "glb": - media_type = "model/gltf-binary" - elif ext == "ply": - media_type = "application/ply" - - # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). - return cached_file_response(request, preview_path, media_type, preview_filename) - - except Exception: - logger.exception( - "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, - ) diff --git a/B04_PreProcess/B04_PreProcess_Router_Progress.py b/B04_PreProcess/B04_PreProcess_Router_Progress.py new file mode 100644 index 00000000..58adfd6d --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Progress.py @@ -0,0 +1,47 @@ +"""B04 지표면 분석 진행률 파일 입출력. + +`B04_PreProcess_Router` 가 700줄을 넘겨 분리한 조각이다(2026-09-04). 라우터 본체와 +상태 조회 라우터가 **같은 함수**를 써야 해서 여기에 둔다 — 순환 임포트를 피하려고 +양쪽이 이 모듈을 바라본다. 동작·경로·값은 옮기기 전 그대로다. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json + +logger = logging.getLogger(__name__) + +# 분석 진행률 파일: B04 산출 폴더 아래에 원자적으로 기록/조회한다. +PROGRESS_FILE_RELATIVE = ("B04_PreProcess", "processed", "progress.json") + + +def _progress_file_path(project_root: Path) -> Path: + return project_root.joinpath(*PROGRESS_FILE_RELATIVE) + + +def write_surface_progress(project_root: Path, percent: int, stage: str, message: str) -> None: + """WF1 분석 진행률을 progress.json에 원자적으로 기록한다 (실패해도 분석은 계속).""" + try: + path = _progress_file_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json( + path, + {"progress_percent": percent, "current_stage": stage, "message": message}, + ) + except OSError: + logger.warning("WF1 진행률 기록 실패: %s", project_root, exc_info=True) + + +def read_surface_progress(project_root: Path) -> dict[str, Any] | None: + """progress.json을 읽어 반환한다. 없거나 손상 시 None.""" + path = _progress_file_path(project_root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None diff --git a/B04_PreProcess/B04_PreProcess_Router_Status.py b/B04_PreProcess/B04_PreProcess_Router_Status.py new file mode 100644 index 00000000..b4461c28 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Status.py @@ -0,0 +1,223 @@ +"""B04 지표면 분석 **상태 조회·모델 프리뷰** 라우터. + +`B04_PreProcess_Router` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). 경로·응답·로직은 +옮기기 전 그대로이고, 본체가 `include_router` 로 이 라우터를 그대로 실어 붙인다. +""" + +import logging +from pathlib import Path +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress +from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_initial_snapshot import is_designing +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +# prefix 는 본체(`_Router`)가 `include_router` 할 때 붙는다 — 여기서 또 주면 +# 경로가 `/api/projects/api/projects/...` 로 겹친다. +router = APIRouter(tags=["B04 Surface Analysis"]) + + +@router.get("/{project_id}/surface/status") +async def get_wf1_analysis_status(project_id: UUID) -> dict: + """WF1 분석 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT state, progress_percent, message, + (SELECT COUNT(*) FROM surface_models + WHERE project_id = %s) as model_count + FROM project_workflow_stages + WHERE project_id = %s AND stage_no = 1 + """, + (str(project_id), str(project_id)), + ) + row = await cursor.fetchone() + + # 만약 새 테이블에 정보가 없다면 기존 projects 테이블에서 조회 (백필 미작동 대비) + if not row: + await cursor.execute( + """ + SELECT p.status as project_status, COUNT(sm.id) as model_count + FROM projects p + LEFT JOIN surface_models sm ON sm.project_id = p.id + WHERE p.id = %s AND p.deleted_at IS NULL + GROUP BY p.id, p.status + """, + (str(project_id),), + ) + fallback_row = await cursor.fetchone() + if not fallback_row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."}, + ) + model_count = int(fallback_row["model_count"]) + project_status = str(fallback_row.get("project_status") or "NEW") + + if project_status == "WF1_FAILED": + state = "FAILED" + progress_percent = 0 + message = "WF1 분석에 실패했습니다." + elif model_count > 0 or project_status == "WF1_COMPLETE": + state = "COMPLETE" + progress_percent = 100 + message = "WF1 분석이 완료되었습니다." + elif project_status == "WF1_ANALYZING": + state = "IN_PROGRESS" + progress_percent = 30 + message = "WF1 분석이 진행 중입니다." + else: + state = "NOT_STARTED" + progress_percent = 0 + message = "WF1 분석 대기 중입니다." + else: + state = row["state"] + progress_percent = row["progress_percent"] + message = row["message"] or "" + model_count = int(row["model_count"]) + + if state == "FAILED": + status = "failed" + current_stage = "failed" + if not message: + message = "WF1 분석에 실패했습니다." + elif state == "COMPLETE": + status = "completed" + progress_percent = 100 + current_stage = "completed" + if not message: + message = "WF1 분석이 완료되었습니다." + elif state == "IN_PROGRESS": + status = "in_progress" + current_stage = "surface_analysis" + if not message: + message = "WF1 분석이 진행 중입니다." + # 진행률 파일이 있으면 실제 단계별 진행률로 대체 + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + progress = read_surface_progress(Path(resolve_stored_project_path(stored_path))) + if progress: + progress_percent = int(progress.get("progress_percent", progress_percent)) + current_stage = str(progress.get("current_stage", current_stage)) + message = str(progress.get("message", message)) + except LookupError: + pass + else: + status = "pending" + progress_percent = 0 + current_stage = "pending" + if not message: + message = "WF1 분석 대기 중입니다." + + # 초기 설계 체인이 도는 동안은 아직 들어갈 때가 아니다 — WF1(stage 1)이 COMPLETE라도 + # 마커가 있으면 진행 중으로 돌려준다(2026-08-29 사용자 확정, CLAUDE.md 5장). + # 여기서 덮어쓰는 이유: 위 분기는 stage 1만 보므로 체인 구간을 "완료"로 답한다. + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + if stored_path and is_designing(Path(resolve_stored_project_path(stored_path))): + status = "in_progress" + current_stage = "initial_design" + message = "초기 설계를 계산하는 중입니다." + except (LookupError, OSError, ValueError): + pass + + return { + "project_id": str(project_id), + "status": status, + "model_count": model_count, + "progress_percent": progress_percent, + "current_stage": current_stage, + "message": message, + } + except Exception: + logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None) +async def get_surface_model_preview( + request: Request, + project_id: UUID, + model_id: int, + smooth: bool = False, +) -> Response | JSONResponse: + """지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor() as cursor: + await cursor.execute( + """ + SELECT model_type, model_file_path + FROM surface_models + WHERE id = %s AND project_id = %s + """, + (model_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."}, + ) + model_type, model_file_path = row[0], row[1] + project_root = Path(resolve_stored_project_path(stored_path)) + if not model_file_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "모델 파일 경로가 없습니다."}, + ) + model_path = project_root / model_file_path + models_dir = model_path.parent + stem = model_path.stem + + ext = "ply" if model_type == "meshfree" else "glb" + if smooth and model_type in ("dtm", "tin"): + preview_filename = f"{stem}_smooth_preview.glb" + else: + preview_filename = f"{stem}_preview.{ext}" + + preview_path = models_dir / preview_filename + if not preview_path.is_file(): + return JSONResponse( + status_code=404, + content={ + "status": "error", + "message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.", + }, + ) + + media_type = "application/octet-stream" + if ext == "glb": + media_type = "model/gltf-binary" + elif ext == "ply": + media_type = "application/ply" + + # 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다). + return cached_file_response(request, preview_path, media_type, preview_filename) + + except Exception: + logger.exception( + "지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."}, + ) -- 2.54.0 From c4aac6f29949dee82121552b25f76d6f7a592c31 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:15:19 +0900 Subject: [PATCH 070/441] =?UTF-8?q?refactor(B03):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20700=EC=A4=84?= =?UTF-8?q?=20=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=C2=B7=EC=B2=AD=ED=81=AC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 872줄 한 파일을 셋으로 나눔 (동작 불변, 순수 분리). - `B03_FileInput_Router.py` 309줄 — 파일 업로드·현황·워크플로 엔드포인트 - `B03_FileInput_Router_Chunks.py` 390줄 — 세션 생성·조각 전송·마무리·진행 조회 (자체 APIRouter 를 본체가 `include_router` 로 붙여 경로 문자열 불변) - `B03_FileInput_Router_Helpers.py` 268줄 — 필수 파일 판정·중복 지문 판별·단계 기록·알림 검증: 라우트 7개 경로·메서드 동일(`GET upload-overview|upload-status|workflow-state`, `POST files|upload-sessions|chunks|finalize`), 공용 브라우저에서 노선 5종 실제 업로드 성공 (shp metadata preview_path 포함), ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Router.py | 588 +----------------- B03_FileInput/B03_FileInput_Router_Chunks.py | 389 ++++++++++++ B03_FileInput/B03_FileInput_Router_Helpers.py | 267 ++++++++ 3 files changed, 667 insertions(+), 577 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_Router_Chunks.py create mode 100644 B03_FileInput/B03_FileInput_Router_Helpers.py diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 222b0666..2867abd5 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -5,302 +5,59 @@ import json import logging from pathlib import Path from typing import Any -from uuid import UUID, uuid4 +from uuid import UUID import aiomysql from fastapi import APIRouter, Depends, File, Form, UploadFile from fastapi.responses import JSONResponse -from B03_FileInput.B03_FileInput_Email import ( - send_file_upload_complete_email, -) from B03_FileInput.B03_FileInput_Engine import ( - merge_upload_chunks, - remove_chunk_session, - resolve_chunk_session_dir, resolve_upload_destination, - save_upload_chunk, save_upload_stream, ) from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, - create_upload_session, - find_input_file_by_name, get_project_input_readiness, get_project_storage_relative_path, - get_upload_session, - list_completed_chunk_indexes, list_incomplete_upload_sessions, list_project_input_files, - mark_upload_session_completed, - mark_upload_session_failed, - supersede_previous_input_files, - upsert_upload_chunk, ) +from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _REQUIRED_FILE_TYPES, + _complete_file_input_if_ready, + _missing_required_file_types, + _schedule_background_task, + _write_stage_metadata, +) from B03_FileInput.B03_FileInput_Schema import ( - ChunkSessionCreateRequest, - ChunkSessionCreateResponse, - ChunkUploadResponse, FileUploadDescriptor, FileUploadResponse, UploadedFileResult, - UploadFinalizeRequest, UploadOverviewFile, UploadOverviewResponse, UploadOverviewSession, - UploadStatusResponse, ) from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email from common_util.common_util_auth import verify_session -from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot from common_util.common_util_json import atomic_write_json -from common_util.common_util_project_reset import purge_project_outputs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow import load_project_workflow from common_util.common_util_workflow_state import ( - complete_stage, get_workflow_state, - is_analysis_running, - reset_stages_after_input_change, ) from config.config_db import get_db_pool from config.config_system import ( - UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_FILES, ) -_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요." - logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B03 File Input"]) -_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) -_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) -# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). -_ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) -# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다. -# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다. -_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"}) - - -def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: - return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) - - -def _is_point_cloud_result(result: UploadedFileResult) -> bool: - """포인트클라우드 결과인지 — 임시 보관함 안내 메일 경로에서 쓴다. - - 프로젝트 업로드 경로는 더 이상 이 판정으로 메일을 보내지 않는다 - ([[_send_upload_complete_notification]] 주석 참고). - """ - return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES - - -def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: - missing = sorted(_REQUIRED_FILE_TYPES - file_types) - if not file_types.intersection(_ROUTE_FILE_TYPES): - missing.append("csv/shp") - # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. - if "shp" in file_types: - missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) - # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. - if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): - missing.append("las/laz") - return missing - - -def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None: - missing = _missing_required_file_types(file_types, las_free) - if missing: - raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") - - -def _stored_fingerprint(metadata: Any) -> str | None: - """입력 파일 메타데이터에 적어 둔 지문을 꺼낸다.""" - if isinstance(metadata, str): - try: - metadata = json.loads(metadata) - except (TypeError, ValueError): - return None - if not isinstance(metadata, dict): - return None - value = metadata.get("fingerprint") - return str(value) if value else None - - -async def _already_uploaded( - connection: aiomysql.Connection, - project_id: UUID, - payload: ChunkSessionCreateRequest, -) -> ChunkSessionCreateResponse | None: - """같은 이름으로 **같은 내용**이 이미 올라와 있으면 전송을 건너뛰라는 응답을 만든다. - - 1.7GB를 다 받은 뒤에 비교하면 아낄 게 없으므로, 세션을 만들기 전에 화면이 보내 준 - 지문으로 가린다. 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽이 안전하다. - """ - if not payload.fingerprint: - return None - existing = await find_input_file_by_name(connection, project_id, payload.original_filename) - if not existing or _stored_fingerprint(existing.get("metadata")) != payload.fingerprint: - return None - logger.info( - "B03 같은 파일 재업로드 — 전송 생략: project_id=%s file=%s", - project_id, - payload.original_filename, - ) - return ChunkSessionCreateResponse( - project_id=str(project_id), - upload_session_id="", - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=payload.chunk_size_bytes, - total_chunks=0, - already_uploaded=True, - ) - - -async def _complete_file_input_if_ready( - connection: aiomysql.Connection, - project_id: UUID, - las_free: bool = False, -) -> int: - file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( - connection, project_id - ) - _require_complete_file_set(file_types, las_free) - if point_cloud_input_id is None: - if not las_free: - raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") - if route_csv_input_id is None: - raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.") - # 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면 - # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - clear_designing(project_root) - discard_initial_snapshot(project_root) - await purge_project_outputs(connection, str(project_id), project_root) - async with connection.cursor(aiomysql.DictCursor) as cursor: - await reset_stages_after_input_change(cursor, str(project_id)) - await complete_stage(cursor, str(project_id), 0) - # LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다. - return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id) - - -def _write_stage_metadata( - stage_root: Path, - project_id: UUID, - results: list[UploadedFileResult], -) -> None: - metadata_path = stage_root / "metadata.json" - existing_files: list[dict[str, Any]] = [] - if metadata_path.exists(): - try: - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - existing_files = list(payload.get("files") or []) - except (OSError, TypeError, ValueError): - logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) - - merged = { - str(item.get("relative_path") or item.get("original_filename")): item - for item in existing_files - } - for result in results: - dumped = result.model_dump() - merged[result.relative_path] = dumped - atomic_write_json( - metadata_path, - {"project_id": str(project_id), "files": list(merged.values())}, - ) - - -# 실행 중인 백그라운드 작업의 강한 참조. 이벤트 루프는 작업을 약한 참조로만 들고 있어, -# 여기서 붙잡지 않으면 GC가 대기 중인 작업을 통째로 회수해 WF1 분석이 조용히 사라진다 -# (업로드는 성공·stage 0은 COMPLETE인데 stage 1은 NOT_STARTED로 남는 증상). -_BACKGROUND_TASKS: set[asyncio.Task] = set() - - -def _schedule_background_task(coro: Any, *, task_name: str) -> None: - task = asyncio.create_task(coro, name=task_name) - _BACKGROUND_TASKS.add(task) - - def _log_task_failure(completed: asyncio.Task) -> None: - _BACKGROUND_TASKS.discard(completed) - try: - completed.result() - except asyncio.CancelledError: - logger.warning("백그라운드 작업 취소됨: %s", task_name) - except Exception: - logger.exception("백그라운드 작업 실패: %s", task_name) - - task.add_done_callback(_log_task_failure) - logger.info("백그라운드 작업 시작: %s", task_name) - - -async def _get_project_notification_info( - connection: aiomysql.Connection, - project_id: UUID, -) -> dict[str, Any] | None: - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """ - SELECT - p.id, - p.name AS project_name, - u.email AS user_email, - u.name AS user_name - FROM projects p - JOIN users u ON u.id = p.user_id - WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL - """, - (str(project_id),), - ) - row = await cursor.fetchone() - return dict(row) if row else None - - -async def _update_project_status(project_id: UUID, status: str) -> None: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute( - """ - UPDATE projects - SET status = %s, updated_at = NOW() - WHERE id = %s AND deleted_at IS NULL - """, - (status, str(project_id)), - ) - await connection.commit() - - -async def _send_upload_complete_notification( - *, - project_id: UUID, - uploaded_file: UploadedFileResult, -) -> None: - """저장만 끝났을 때 보내는 안내. - - 프로젝트 업로드 경로에서는 호출하지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 - 통합 메일 한 통으로 알린다. 프로젝트 생성 전 임시 보관함 업로드에서 쓸 예정이라 - 지워두지 않았다(2026-08-08 사용자 지시). - """ - pool = get_db_pool() - async with pool.acquire() as connection: - project_info = await _get_project_notification_info(connection, project_id) - if not project_info or not project_info.get("user_email"): - logger.warning("업로드 완료 이메일 수신자 없음: project_id=%s", project_id) - return - - await send_file_upload_complete_email( - to_email=str(project_info["user_email"]), - user_name=str(project_info.get("user_name") or "사용자"), - project_name=str(project_info.get("project_name") or project_id), - file_name=uploaded_file.original_filename, - file_size_mb=uploaded_file.size_bytes / (1024 * 1024), - metadata=uploaded_file.metadata, - ) +# 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다. +router.include_router(chunk_router) @router.post("/{project_id}/files", response_model=FileUploadResponse) @@ -458,329 +215,6 @@ async def upload_project_files( await upload.close() -@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse) -async def create_project_upload_session( - project_id: UUID, - payload: ChunkSessionCreateRequest, - session: dict[str, Any] = Depends(verify_session), -) -> ChunkSessionCreateResponse | JSONResponse: - """대용량 파일 청크 업로드 세션을 생성한다.""" - # LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로 - # 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다. - if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}: - return JSONResponse( - status_code=400, - content={ - "status": "error", - "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", - }, - ) - chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) - total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) - session_id = str(uuid4()) - point_cloud_input_id: int | None = None - skipped: ChunkSessionCreateResponse | None = None - - pool = get_db_pool() - try: - async with pool.acquire() as connection: - await get_project_storage_relative_path(connection, project_id) - # 분석이 도는 중이면 새 자료를 받지 않는다 — 받아 봐야 분석 2개가 같은 산출물 - # 경로에서 부딪힌다. 화면도 버튼을 잠그지만 새로고침으로 우회할 수 있어 여기서 막는다. - async with connection.cursor(aiomysql.DictCursor) as cursor: - if await is_analysis_running(cursor, str(project_id)): - return JSONResponse( - status_code=409, - content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, - ) - skipped = await _already_uploaded(connection, project_id, payload) - if skipped is not None: - if payload.complete_upload: - await connection.begin() - try: - point_cloud_input_id = await _complete_file_input_if_ready( - connection, - project_id, - payload.las_free, - ) - await connection.commit() - except Exception: - await connection.rollback() - raise - else: - await create_upload_session( - connection, - session_id=session_id, - project_id=project_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - if skipped is not None: - if point_cloud_input_id is not None: - _schedule_background_task( - trigger_wf1_analysis_and_email( - project_id=project_id, - input_file_id=point_cloud_input_id, - user_role=str(session["role"]), - ), - task_name=f"b04-preprocess-auto-{project_id}", - ) - return skipped - return ChunkSessionCreateResponse( - project_id=str(project_id), - upload_session_id=session_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, - ) - - -@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse) -async def upload_project_chunk( - project_id: UUID, - session_id: str = Form(...), - chunk_index: int = Form(...), - chunk_data: UploadFile = File(...), -) -> ChunkUploadResponse | JSONResponse: - """단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=session_id, - ) - if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - session_dir = resolve_chunk_session_dir(project_root, session_id) - chunk_path, size_bytes, chunk_hash = await save_upload_chunk( - chunk_data, - session_dir, - chunk_index, - expected_max_bytes=int(upload_session["chunk_size_bytes"]), - ) - relative_chunk_path = chunk_path.relative_to(project_root).as_posix() - completed_chunks = await upsert_upload_chunk( - connection, - session_id=session_id, - chunk_index=chunk_index, - chunk_hash=chunk_hash, - size_bytes=size_bytes, - stored_at=relative_chunk_path, - ) - return ChunkUploadResponse( - upload_session_id=session_id, - chunk_index=chunk_index, - completed_chunks=completed_chunks, - total_chunks=int(upload_session["total_chunks"]), - chunk_hash=chunk_hash, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception( - "B03 청크 업로드 실패: project_id=%s session_id=%s", - project_id, - session_id, - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, - ) - finally: - await chunk_data.close() - - -@router.post("/{project_id}/finalize", response_model=FileUploadResponse) -async def finalize_project_upload( - project_id: UUID, - payload: UploadFinalizeRequest, - session: dict[str, Any] = Depends(verify_session), -) -> FileUploadResponse | JSONResponse: - """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" - pool = get_db_pool() - final_path: Path | None = None - point_cloud_input_id: int | None = None - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=payload.session_id, - ) - if int(upload_session["total_chunks"]) != payload.total_chunks: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, - session_id=payload.session_id, - ) - expected_indexes = list(range(payload.total_chunks)) - if completed_indexes != expected_indexes: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, - ) - - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)) - descriptor = FileUploadDescriptor( - original_filename=str(upload_session["original_filename"]), - size_bytes=int(upload_session["file_size_bytes"]), - ) - final_path = merge_upload_chunks( - project_root, - descriptor, - payload.session_id, - payload.total_chunks, - ) - metadata = await asyncio.to_thread(analyze_input_metadata, final_path) - # 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다. - fingerprint = payload.fingerprint or None - if fingerprint: - metadata = {**metadata, "fingerprint": fingerprint} - relative_path = final_path.relative_to(project_root).as_posix() - file_type = final_path.suffix.lower().lstrip(".") - crs_epsg = metadata.get("epsg") - - await connection.begin() - try: - input_file_id = await create_input_file( - connection, - project_id=project_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - file_size_bytes=descriptor.size_bytes, - upload_by=None, - crs_epsg=int(crs_epsg) if crs_epsg is not None else None, - metadata=metadata, - ) - # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. - await supersede_previous_input_files( - connection, - project_id, - descriptor.original_filename, - input_file_id, - ) - await mark_upload_session_completed(connection, session_id=payload.session_id) - if payload.complete_upload: - point_cloud_input_id = await _complete_file_input_if_ready( - connection, - project_id, - payload.las_free, - ) - await connection.commit() - except Exception: - await connection.rollback() - await mark_upload_session_failed(connection, session_id=payload.session_id) - raise - - remove_chunk_session(project_root, payload.session_id) - result = UploadedFileResult( - input_file_id=input_file_id, - original_filename=descriptor.original_filename, - file_type=file_type, - relative_path=relative_path, - size_bytes=descriptor.size_bytes, - metadata=metadata, - ) - stage_root = project_root / "B03_FileInput" - _write_stage_metadata(stage_root, project_id, [result]) - # 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유. - if point_cloud_input_id is not None: - _schedule_background_task( - trigger_wf1_analysis_and_email( - project_id=project_id, - input_file_id=point_cloud_input_id, - user_role=str(session["role"]), - ), - task_name=f"b04-preprocess-auto-{project_id}", - ) - return FileUploadResponse(project_id=str(project_id), files=[result]) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - if final_path is not None: - final_path.unlink(missing_ok=True) - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - if final_path is not None: - final_path.unlink(missing_ok=True) - logger.exception("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, - ) - - -@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse) -async def get_project_upload_status( - project_id: UUID, - session_id: str, -) -> UploadStatusResponse | JSONResponse: - """업로드 세션의 청크 완료 상태를 조회한다.""" - pool = get_db_pool() - try: - async with pool.acquire() as connection: - upload_session = await get_upload_session( - connection, - project_id=project_id, - session_id=session_id, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, - session_id=session_id, - ) - return UploadStatusResponse( - upload_session_id=session_id, - upload_status=str(upload_session["status"]), - original_filename=str(upload_session["original_filename"]), - file_size_bytes=int(upload_session["file_size_bytes"]), - chunk_size_bytes=int(upload_session["chunk_size_bytes"]), - total_chunks=int(upload_session["total_chunks"]), - completed_chunks=len(completed_indexes), - completed_chunk_indexes=completed_indexes, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception( - "B03 업로드 상태 조회 실패: project_id=%s session_id=%s", - project_id, - session_id, - ) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, - ) - - def _parse_metadata(raw: Any) -> dict[str, Any] | None: """DB에 JSON 문자열로 저장된 분석 메타데이터를 dict로 돌린다(깨지면 생략).""" if isinstance(raw, dict): diff --git a/B03_FileInput/B03_FileInput_Router_Chunks.py b/B03_FileInput/B03_FileInput_Router_Chunks.py new file mode 100644 index 00000000..0ded7774 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Chunks.py @@ -0,0 +1,389 @@ +"""B03 청크 업로드 엔드포인트 — 세션 생성·조각 전송·마무리·진행 조회. + +라우터 본체(`B03_FileInput_Router.py`)가 700줄을 넘어 떼어낸 조각이다(2026-09-04). +경로·태그는 본체와 같다 — 본체가 `include_router` 로 붙여 URL 이 그대로 유지된다. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import aiomysql +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + merge_upload_chunks, + remove_chunk_session, + resolve_chunk_session_dir, + save_upload_chunk, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + create_upload_session, + get_project_storage_relative_path, + get_upload_session, + list_completed_chunk_indexes, + mark_upload_session_completed, + mark_upload_session_failed, + supersede_previous_input_files, + upsert_upload_chunk, +) +from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ANALYSIS_RUNNING_MESSAGE, + _already_uploaded, + _complete_file_input_if_ready, + _schedule_background_task, + _total_chunks, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + ChunkUploadResponse, + FileUploadDescriptor, + FileUploadResponse, + UploadedFileResult, + UploadFinalizeRequest, + UploadStatusResponse, +) +from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email +from common_util.common_util_auth import verify_session +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import ( + is_analysis_running, +) +from config.config_db import get_db_pool +from config.config_system import ( + UPLOAD_CHUNK_SIZE_BYTES, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["B03 File Input"]) + + +@router.post("/{project_id}/upload-sessions", response_model=ChunkSessionCreateResponse) +async def create_project_upload_session( + project_id: UUID, + payload: ChunkSessionCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> ChunkSessionCreateResponse | JSONResponse: + """대용량 파일 청크 업로드 세션을 생성한다.""" + # LAS 없는 설계를 켠 상태면 포인트클라우드는 받지 않는다 — 큰 LAS는 이 경로로 + # 들어오므로 여기서 막지 않으면 `/files` 검사를 통째로 비켜 간다. + if payload.las_free and Path(payload.original_filename).suffix.lower() in {".las", ".laz"}: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", + }, + ) + chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) + total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) + session_id = str(uuid4()) + point_cloud_input_id: int | None = None + skipped: ChunkSessionCreateResponse | None = None + + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_project_storage_relative_path(connection, project_id) + # 분석이 도는 중이면 새 자료를 받지 않는다 — 받아 봐야 분석 2개가 같은 산출물 + # 경로에서 부딪힌다. 화면도 버튼을 잠그지만 새로고침으로 우회할 수 있어 여기서 막는다. + async with connection.cursor(aiomysql.DictCursor) as cursor: + if await is_analysis_running(cursor, str(project_id)): + return JSONResponse( + status_code=409, + content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE}, + ) + skipped = await _already_uploaded(connection, project_id, payload) + if skipped is not None: + if payload.complete_upload: + await connection.begin() + try: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + payload.las_free, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + else: + await create_upload_session( + connection, + session_id=session_id, + project_id=project_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + if skipped is not None: + if point_cloud_input_id is not None: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=project_id, + input_file_id=point_cloud_input_id, + user_role=str(session["role"]), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + return skipped + return ChunkSessionCreateResponse( + project_id=str(project_id), + upload_session_id=session_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B03 청크 세션 생성 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, + ) + + +@router.post("/{project_id}/chunks", response_model=ChunkUploadResponse) +async def upload_project_chunk( + project_id: UUID, + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk_data: UploadFile = File(...), +) -> ChunkUploadResponse | JSONResponse: + """단일 파일 청크를 B03 임시 폴더에 저장하고 DB에 기록한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=session_id, + ) + if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + session_dir = resolve_chunk_session_dir(project_root, session_id) + chunk_path, size_bytes, chunk_hash = await save_upload_chunk( + chunk_data, + session_dir, + chunk_index, + expected_max_bytes=int(upload_session["chunk_size_bytes"]), + ) + relative_chunk_path = chunk_path.relative_to(project_root).as_posix() + completed_chunks = await upsert_upload_chunk( + connection, + session_id=session_id, + chunk_index=chunk_index, + chunk_hash=chunk_hash, + size_bytes=size_bytes, + stored_at=relative_chunk_path, + ) + return ChunkUploadResponse( + upload_session_id=session_id, + chunk_index=chunk_index, + completed_chunks=completed_chunks, + total_chunks=int(upload_session["total_chunks"]), + chunk_hash=chunk_hash, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B03 청크 업로드 실패: project_id=%s session_id=%s", + project_id, + session_id, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, + ) + finally: + await chunk_data.close() + + +@router.post("/{project_id}/finalize", response_model=FileUploadResponse) +async def finalize_project_upload( + project_id: UUID, + payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), +) -> FileUploadResponse | JSONResponse: + """청크 업로드를 최종 병합하고 input_files 메타데이터를 기록한다.""" + pool = get_db_pool() + final_path: Path | None = None + point_cloud_input_id: int | None = None + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=payload.session_id, + ) + if int(upload_session["total_chunks"]) != payload.total_chunks: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, + session_id=payload.session_id, + ) + expected_indexes = list(range(payload.total_chunks)) + if completed_indexes != expected_indexes: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, + ) + + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + descriptor = FileUploadDescriptor( + original_filename=str(upload_session["original_filename"]), + size_bytes=int(upload_session["file_size_bytes"]), + ) + final_path = merge_upload_chunks( + project_root, + descriptor, + payload.session_id, + payload.total_chunks, + ) + metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + # 다음에 같은 파일이 올라오면 전송을 건너뛸 수 있도록 지문을 함께 남긴다. + fingerprint = payload.fingerprint or None + if fingerprint: + metadata = {**metadata, "fingerprint": fingerprint} + relative_path = final_path.relative_to(project_root).as_posix() + file_type = final_path.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + + await connection.begin() + try: + input_file_id = await create_input_file( + connection, + project_id=project_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=descriptor.size_bytes, + upload_by=None, + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + # 같은 이름의 옛 행은 내려 둔다 — 목록·분석이 최신 1건만 보게 한다. + await supersede_previous_input_files( + connection, + project_id, + descriptor.original_filename, + input_file_id, + ) + await mark_upload_session_completed(connection, session_id=payload.session_id) + if payload.complete_upload: + point_cloud_input_id = await _complete_file_input_if_ready( + connection, + project_id, + payload.las_free, + ) + await connection.commit() + except Exception: + await connection.rollback() + await mark_upload_session_failed(connection, session_id=payload.session_id) + raise + + remove_chunk_session(project_root, payload.session_id) + result = UploadedFileResult( + input_file_id=input_file_id, + original_filename=descriptor.original_filename, + file_type=file_type, + relative_path=relative_path, + size_bytes=descriptor.size_bytes, + metadata=metadata, + ) + stage_root = project_root / "B03_FileInput" + _write_stage_metadata(stage_root, project_id, [result]) + # 업로드 직후 안내 메일은 보내지 않는다 — 위 일반 업로드 경로와 같은 이유. + if point_cloud_input_id is not None: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=project_id, + input_file_id=point_cloud_input_id, + user_role=str(session["role"]), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + return FileUploadResponse(project_id=str(project_id), files=[result]) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + if final_path is not None: + final_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + if final_path is not None: + final_path.unlink(missing_ok=True) + logger.exception("B03 청크 업로드 최종 병합 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, + ) + + +@router.get("/{project_id}/upload-status/{session_id}", response_model=UploadStatusResponse) +async def get_project_upload_status( + project_id: UUID, + session_id: str, +) -> UploadStatusResponse | JSONResponse: + """업로드 세션의 청크 완료 상태를 조회한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + upload_session = await get_upload_session( + connection, + project_id=project_id, + session_id=session_id, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, + session_id=session_id, + ) + return UploadStatusResponse( + upload_session_id=session_id, + upload_status=str(upload_session["status"]), + original_filename=str(upload_session["original_filename"]), + file_size_bytes=int(upload_session["file_size_bytes"]), + chunk_size_bytes=int(upload_session["chunk_size_bytes"]), + total_chunks=int(upload_session["total_chunks"]), + completed_chunks=len(completed_indexes), + completed_chunk_indexes=completed_indexes, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception( + "B03 업로드 상태 조회 실패: project_id=%s session_id=%s", + project_id, + session_id, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, + ) diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py new file mode 100644 index 00000000..7b895adb --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -0,0 +1,267 @@ +"""B03 파일 입력 라우터 보조 — 필수 파일 판정·중복 업로드 판별·단계 기록·알림. + +라우터 본체(`B03_FileInput_Router.py`)가 700줄을 넘어 떼어낸 조각이다(2026-09-04). +엔드포인트는 두지 않는다 — 순수 보조 함수와 파일 종류 상수만 둔다. +""" + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql + +from B03_FileInput.B03_FileInput_Email import ( + send_file_upload_complete_email, +) +from B03_FileInput.B03_FileInput_Repository import ( + find_input_file_by_name, + get_project_input_readiness, + get_project_storage_relative_path, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + UploadedFileResult, +) +from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot +from common_util.common_util_json import atomic_write_json +from common_util.common_util_project_reset import purge_project_outputs +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import ( + complete_stage, + reset_stages_after_input_change, +) +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요." + +_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"}) +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) +# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile). +_ROUTE_FILE_TYPES = frozenset({"csv", "shp"}) +# shapefile은 이것들이 다 있어야 열린다. `.cpg`는 없으면 CP949로 읽으므로 필수가 아니다. +# `route_prj`는 노선 세트 폴더에 있는 PRJ — 지형 PRJ(`prj`)와 따로 센다. +_SHAPEFILE_REQUIRED_TYPES = frozenset({"shx", "dbf", "route_prj"}) + + +def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: + return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) + + +def _is_point_cloud_result(result: UploadedFileResult) -> bool: + """포인트클라우드 결과인지 — 임시 보관함 안내 메일 경로에서 쓴다. + + 프로젝트 업로드 경로는 더 이상 이 판정으로 메일을 보내지 않는다 + ([[_send_upload_complete_notification]] 주석 참고). + """ + return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES + + +def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: + missing = sorted(_REQUIRED_FILE_TYPES - file_types) + if not file_types.intersection(_ROUTE_FILE_TYPES): + missing.append("csv/shp") + # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. + if "shp" in file_types: + missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) + # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. + if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): + missing.append("las/laz") + return missing + + +def _require_complete_file_set(file_types: set[str], las_free: bool = False) -> None: + missing = _missing_required_file_types(file_types, las_free) + if missing: + raise ValueError(f"B03 필수 입력 파일이 없습니다: {', '.join(missing)}") + + +def _stored_fingerprint(metadata: Any) -> str | None: + """입력 파일 메타데이터에 적어 둔 지문을 꺼낸다.""" + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except (TypeError, ValueError): + return None + if not isinstance(metadata, dict): + return None + value = metadata.get("fingerprint") + return str(value) if value else None + + +async def _already_uploaded( + connection: aiomysql.Connection, + project_id: UUID, + payload: ChunkSessionCreateRequest, +) -> ChunkSessionCreateResponse | None: + """같은 이름으로 **같은 내용**이 이미 올라와 있으면 전송을 건너뛰라는 응답을 만든다. + + 1.7GB를 다 받은 뒤에 비교하면 아낄 게 없으므로, 세션을 만들기 전에 화면이 보내 준 + 지문으로 가린다. 지문이 없거나 다르면 그냥 올린다 — 애매하면 올리는 쪽이 안전하다. + """ + if not payload.fingerprint: + return None + existing = await find_input_file_by_name(connection, project_id, payload.original_filename) + if not existing or _stored_fingerprint(existing.get("metadata")) != payload.fingerprint: + return None + logger.info( + "B03 같은 파일 재업로드 — 전송 생략: project_id=%s file=%s", + project_id, + payload.original_filename, + ) + return ChunkSessionCreateResponse( + project_id=str(project_id), + upload_session_id="", + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=payload.chunk_size_bytes, + total_chunks=0, + already_uploaded=True, + ) + + +async def _complete_file_input_if_ready( + connection: aiomysql.Connection, + project_id: UUID, + las_free: bool = False, +) -> int: + file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness( + connection, project_id + ) + _require_complete_file_set(file_types, las_free) + if point_cloud_input_id is None: + if not las_free: + raise ValueError("LAS 또는 LAZ 입력 파일을 찾을 수 없습니다.") + if route_csv_input_id is None: + raise ValueError("계획 노선 입력 파일(CSV 또는 shapefile)을 찾을 수 없습니다.") + # 자료가 갈렸으니 옛 계산 결과(파일 + DB)를 지우고 진행 표시도 되돌린다. 남겨 두면 + # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + clear_designing(project_root) + discard_initial_snapshot(project_root) + await purge_project_outputs(connection, str(project_id), project_root) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await reset_stages_after_input_change(cursor, str(project_id)) + await complete_stage(cursor, str(project_id), 0) + # LAS가 있으면 LAS, 없으면(las_free) 계획노선 CSV가 WF1 분석 입력이다. + return point_cloud_input_id if point_cloud_input_id is not None else int(route_csv_input_id) + + +def _write_stage_metadata( + stage_root: Path, + project_id: UUID, + results: list[UploadedFileResult], +) -> None: + metadata_path = stage_root / "metadata.json" + existing_files: list[dict[str, Any]] = [] + if metadata_path.exists(): + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + existing_files = list(payload.get("files") or []) + except (OSError, TypeError, ValueError): + logger.warning("B03 metadata.json을 읽지 못해 새로 작성합니다: %s", metadata_path) + + merged = { + str(item.get("relative_path") or item.get("original_filename")): item + for item in existing_files + } + for result in results: + dumped = result.model_dump() + merged[result.relative_path] = dumped + atomic_write_json( + metadata_path, + {"project_id": str(project_id), "files": list(merged.values())}, + ) + + +# 실행 중인 백그라운드 작업의 강한 참조. 이벤트 루프는 작업을 약한 참조로만 들고 있어, +# 여기서 붙잡지 않으면 GC가 대기 중인 작업을 통째로 회수해 WF1 분석이 조용히 사라진다 +# (업로드는 성공·stage 0은 COMPLETE인데 stage 1은 NOT_STARTED로 남는 증상). +_BACKGROUND_TASKS: set[asyncio.Task] = set() + + +def _schedule_background_task(coro: Any, *, task_name: str) -> None: + task = asyncio.create_task(coro, name=task_name) + _BACKGROUND_TASKS.add(task) + + def _log_task_failure(completed: asyncio.Task) -> None: + _BACKGROUND_TASKS.discard(completed) + try: + completed.result() + except asyncio.CancelledError: + logger.warning("백그라운드 작업 취소됨: %s", task_name) + except Exception: + logger.exception("백그라운드 작업 실패: %s", task_name) + + task.add_done_callback(_log_task_failure) + logger.info("백그라운드 작업 시작: %s", task_name) + + +async def _get_project_notification_info( + connection: aiomysql.Connection, + project_id: UUID, +) -> dict[str, Any] | None: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT + p.id, + p.name AS project_name, + u.email AS user_email, + u.name AS user_name + FROM projects p + JOIN users u ON u.id = p.user_id + WHERE p.id = %s AND p.deleted_at IS NULL AND u.deleted_at IS NULL + """, + (str(project_id),), + ) + row = await cursor.fetchone() + return dict(row) if row else None + + +async def _update_project_status(project_id: UUID, status: str) -> None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """ + UPDATE projects + SET status = %s, updated_at = NOW() + WHERE id = %s AND deleted_at IS NULL + """, + (status, str(project_id)), + ) + await connection.commit() + + +async def _send_upload_complete_notification( + *, + project_id: UUID, + uploaded_file: UploadedFileResult, +) -> None: + """저장만 끝났을 때 보내는 안내. + + 프로젝트 업로드 경로에서는 호출하지 않는다 — 그 흐름은 초기 설계까지 마친 뒤 + 통합 메일 한 통으로 알린다. 프로젝트 생성 전 임시 보관함 업로드에서 쓸 예정이라 + 지워두지 않았다(2026-08-08 사용자 지시). + """ + pool = get_db_pool() + async with pool.acquire() as connection: + project_info = await _get_project_notification_info(connection, project_id) + if not project_info or not project_info.get("user_email"): + logger.warning("업로드 완료 이메일 수신자 없음: project_id=%s", project_id) + return + + await send_file_upload_complete_email( + to_email=str(project_info["user_email"]), + user_name=str(project_info.get("user_name") or "사용자"), + project_name=str(project_info.get("project_name") or project_id), + file_name=uploaded_file.original_filename, + file_size_mb=uploaded_file.size_bytes / (1024 * 1024), + metadata=uploaded_file.metadata, + ) -- 2.54.0 From 07e6441d7f2a6215a463f0eea9ead09e208656e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:17:34 +0900 Subject: [PATCH 071/441] =?UTF-8?q?refactor(B04):=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EC=9C=A0=EC=97=AD=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20700=EC=A4=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04_PreProcess_Router_Watershed.py 714줄 -> 518줄. 산출물 기록(.npz·스테이지 기하)과 응답 좌표 변환 헬퍼 9개를 B04_PreProcess_Router_Watershed_Output.py(225줄)로 그대로 옮김. 공개 심볼 router · get_primary_region · CONTOUR_FILE · STREAM_FILE 유지 (main.py · B03_FileInput_Service_Chain · B07_DesignDetail_Router_Support 참조). 검증: ruff check 전체 통과·format 적용, tmp/tests 183 passed, 백엔드 재시작 후 경로 2개 유지, /drainage/rainfall 200, /drainage/primary-region 200(schema_version 3), B05 화면 유역 11개·측점 66개 정상, 옮긴 순수 헬퍼 6개 직접 호출 스모크 통과. 분석 재실행(쓰기 경로)은 작업본 산출물을 덮어써서 돌리지 않음. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Router_Watershed.py | 222 +---------------- .../B04_PreProcess_Router_Watershed_Output.py | 225 ++++++++++++++++++ 2 files changed, 238 insertions(+), 209 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index c6faa675..1ac02733 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -8,7 +8,6 @@ """ import asyncio -import base64 import json import logging import math @@ -16,33 +15,39 @@ from pathlib import Path from typing import Any from uuid import UUID -import numpy as np from fastapi import APIRouter from fastapi.responses import JSONResponse from pyproj import Transformer -from shapely.geometry import Point, Polygon, box +from shapely.geometry import Point from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import preview_stages from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( drainage_dir, - write_grid_arrays, write_stage, ) from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( - AZIMUTH_INVALID, - AZIMUTH_SINK, - AZIMUTH_STEPS, mask_row_spans, ) +from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import ( + _as_polygons, + _boundary_geometry, + _flow_payload, + _grid_bbox_lonlat, + _grid_bbox_polygon, + _line_lonlat, + _polygon_rings, + _write_road_routing, + _write_stage_arrays, +) from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_route_geometry import ( StructureCandidate, find_planned_route_file, load_design_route, read_planned_route, ) -from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_wamis_rainfall import ( @@ -511,204 +516,3 @@ async def get_primary_region( # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. _save_response(prepared["stored_path"], payload) return payload - - -def _write_road_routing( - stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any -) -> None: - """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. - - B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 - 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 - 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). - """ - routing = preview.routing - road = preview.road - if routing is None or road is None or road.count == 0: - return - write_grid_arrays( - stored_path, - "road_routing", - spec, - { - "road_slot": routing.road_slot, - "path_length": routing.path_length, - "strength": routing.strength, - "road_cell_index": road.cell_index, - "road_chainage": road.chainage, - "elevation": preview.terrain.elevation.reshape(-1), - }, - { - "road_cells": road.count, - "reached_cells": int((routing.road_slot >= 0).sum()), - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - }, - ) - # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. - write_stage( - stored_path, - "road_routing", - { - "route": [route_line], - "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), - "pipe": [ - ( - Point(pipe.x, pipe.y), - {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, - ) - for pipe in preview.pipes - ], - # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. - "flow_arrow": [ - ( - Point(x, y), - { - # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. - "x": round(x, 2), - "y": round(y, 2), - "azimuth_deg": round(math.degrees(angle), 1), - "reaches_road": reaches, - "cells": cells, - }, - ) - for x, y, angle, reaches, cells in preview.flow_arrows - ], - }, - { - "basin_area_m2": round(preview.basin_area_m2, 1), - "pipe_count": len(preview.pipes), - "route_length_m": round(route_line.length, 1), - "arrow_count": len(preview.flow_arrows), - "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, - }, - to_lonlat, - ) - - -def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: - """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" - if domain is not None: - write_grid_arrays( - stored_path, - "primary_region", - spec, - {"mask": domain}, - { - "cells": int(domain.sum()), - "bbox_cells": spec.size, - "expand_rounds": preview.expand_rounds, - "expand_closed": preview.expand_closed, - }, - ) - flow = preview.flow - if flow is None: - return - arrays = { - "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), - "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), - "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), - # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. - "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), - } - if flow.burned is not None: - arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) - if preview.descent is not None: - arrays["band_elevation"] = preview.descent.band_elevation - # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. - if preview.strength_profile: - curve = np.asarray(preview.strength_profile, dtype=np.float64) - arrays["strength_chainage_m"] = curve[:, 0] - arrays["strength_area_m2"] = curve[:, 1] - write_grid_arrays( - stored_path, - "flow_direction", - spec, - arrays, - { - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "analyzed": int(flow.analyzed.sum()), - "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), - "no_road": int((~flow.reaches_road & flow.analyzed).sum()), - "burned": 0 if flow.burned is None else int(flow.burned.sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "strength_points": len(preview.strength_profile), - "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), - }, - ) - - -def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: - """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. - - 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: - 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 - 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). - 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. - """ - flow = preview.flow - if flow is None or domain is None: - return None - order = np.flatnonzero(domain.reshape(-1)) - analyzed = flow.analyzed[order] - reaches = flow.reaches_road[order] - packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) - packed |= np.where(reaches, 0x80, 0).astype(np.uint8) - burned = flow.burned - return { - "encoding": "base64-uint8", - "azimuth_steps": AZIMUTH_STEPS, - "sink_code": AZIMUTH_SINK, - "invalid_code": AZIMUTH_INVALID, - "cells": int(order.size), - "reaches_road": int((reaches & analyzed).sum()), - "no_road": int((~reaches & analyzed).sum()), - # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. - "unanalyzed": int((~analyzed).sum()), - # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. - "burned": 0 if burned is None else int(burned[order].sum()), - "outer_seeds": flow.outer_seeds, - "interior_seeds": flow.interior_seeds, - "data": base64.b64encode(packed.tobytes()).decode("ascii"), - } - - -def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: - """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" - return [Polygon(ring)] if len(ring) >= 4 else [] - - -def _as_polygons(geometry: Any) -> list[Any]: - if geometry is None or geometry.is_empty: - return [] - return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] - - -def _grid_bbox_polygon(spec: Any) -> Polygon: - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_min = spec.y_max - spec.n_rows * spec.cell_m - return box(spec.x_min, y_min, x_max, spec.y_max) - - -def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: - return [list(to_lonlat(x, y)) for x, y in line.coords] - - -def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: - """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" - if geometry is None or geometry.is_empty: - return [] - parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] - return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] - - -def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: - x_min = spec.x_min - x_max = spec.x_min + spec.n_cols * spec.cell_m - y_max = spec.y_max - y_min = spec.y_max - spec.n_rows * spec.cell_m - corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) - return [list(to_lonlat(x, y)) for x, y in corners] diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py new file mode 100644 index 00000000..87affee5 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed_Output.py @@ -0,0 +1,225 @@ +"""배수유역 분석 **산출물 기록·응답 기하** 헬퍼. + +`B04_PreProcess_Router_Watershed` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). +B05 가 읽을 `.npz`·스테이지 기하를 남기는 쓰기 함수와, 응답용 좌표 변환 헬퍼만 모았다. +함수 본문·값은 옮기기 전 그대로이고, 라우터가 이 모듈에서 가져다 쓴다. +""" + +import base64 +import math +from typing import Any + +import numpy as np +from shapely.geometry import Point, Polygon, box + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import ( + write_grid_arrays, + write_stage, +) +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + AZIMUTH_INVALID, + AZIMUTH_SINK, + AZIMUTH_STEPS, +) +from config.config_system import DRAINAGE_ARROW_SPACING_M + + +def _write_road_routing( + stored_path: str, preview: Any, spec: Any, route_line: Any, to_lonlat: Any +) -> None: + """B05가 세부유역을 나눌 때 쓸 최소 산출물을 남긴다. + + B05는 일반 사용자용이라 가벼워야 한다. 화살표(방향 코드)·밴드 표고 같은 확인용 배열은 + 빼고, **셀 → 도로 셀 귀속**과 도로 셀 제원만 담는다. 여기에 표고를 함께 넣는 이유는 + 유역 낙차를 내려면 셀 표고가 필요해서다(2026-07-31 사용자 지시). + """ + routing = preview.routing + road = preview.road + if routing is None or road is None or road.count == 0: + return + write_grid_arrays( + stored_path, + "road_routing", + spec, + { + "road_slot": routing.road_slot, + "path_length": routing.path_length, + "strength": routing.strength, + "road_cell_index": road.cell_index, + "road_chainage": road.chainage, + "elevation": preview.terrain.elevation.reshape(-1), + }, + { + "road_cells": road.count, + "reached_cells": int((routing.road_slot >= 0).sum()), + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + }, + ) + # B05가 그대로 그릴 기하 — 계획도로선 · 기본 배관 · 2차 전체 배수유역, 이 셋뿐이다. + write_stage( + stored_path, + "road_routing", + { + "route": [route_line], + "basin_boundary": _boundary_geometry(preview.basin_boundary_xy), + "pipe": [ + ( + Point(pipe.x, pipe.y), + {"chainage_m": round(pipe.chainage_m, 2), "reason": pipe.reason}, + ) + for pipe in preview.pipes + ], + # 평균 흐름 화살표 — B05도 같은 그림을 그려야 하므로 여기 함께 남긴다. + "flow_arrow": [ + ( + Point(x, y), + { + # B05 화면은 사업지 CRS(m)로 그리므로 미터 좌표도 함께 남긴다. + "x": round(x, 2), + "y": round(y, 2), + "azimuth_deg": round(math.degrees(angle), 1), + "reaches_road": reaches, + "cells": cells, + }, + ) + for x, y, angle, reaches, cells in preview.flow_arrows + ], + }, + { + "basin_area_m2": round(preview.basin_area_m2, 1), + "pipe_count": len(preview.pipes), + "route_length_m": round(route_line.length, 1), + "arrow_count": len(preview.flow_arrows), + "arrow_spacing_m": DRAINAGE_ARROW_SPACING_M, + }, + to_lonlat, + ) + + +def _write_stage_arrays(stored_path: str, preview: Any, domain: Any, spec: Any) -> None: + """격자 규모 배열(셀 마스크·흐름 방향·도달 여부)을 단계별 `.npz`로 남긴다.""" + if domain is not None: + write_grid_arrays( + stored_path, + "primary_region", + spec, + {"mask": domain}, + { + "cells": int(domain.sum()), + "bbox_cells": spec.size, + "expand_rounds": preview.expand_rounds, + "expand_closed": preview.expand_closed, + }, + ) + flow = preview.flow + if flow is None: + return + arrays = { + "direction": flow.direction.reshape(spec.n_rows, spec.n_cols), + "reaches_road": flow.reaches_road.reshape(spec.n_rows, spec.n_cols), + "analyzed": flow.analyzed.reshape(spec.n_rows, spec.n_cols), + # 사후 진단용 — 수신 셀이 있어야 사슬을 다시 따라가 볼 수 있다. + "receiver": preview.terrain.receiver.reshape(spec.n_rows, spec.n_cols), + } + if flow.burned is not None: + arrays["burned"] = flow.burned.reshape(spec.n_rows, spec.n_cols) + if preview.descent is not None: + arrays["band_elevation"] = preview.descent.band_elevation + # ⑥ 흐름 강도 곡선 — 기하가 아니라 수치 곡선이라 GeoJSON이 아닌 여기에 함께 담는다. + if preview.strength_profile: + curve = np.asarray(preview.strength_profile, dtype=np.float64) + arrays["strength_chainage_m"] = curve[:, 0] + arrays["strength_area_m2"] = curve[:, 1] + write_grid_arrays( + stored_path, + "flow_direction", + spec, + arrays, + { + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "analyzed": int(flow.analyzed.sum()), + "reaches_road": int((flow.reaches_road & flow.analyzed).sum()), + "no_road": int((~flow.reaches_road & flow.analyzed).sum()), + "burned": 0 if flow.burned is None else int(flow.burned.sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "strength_points": len(preview.strength_profile), + "strength_total_m2": round(sum(area for _, area in preview.strength_profile), 1), + }, + ) + + +def _flow_payload(preview: Any, domain: Any) -> dict[str, Any] | None: + """셀별 흐름 방향·도로 도달 여부를 바이트 배열로 압축한다. + + 셀이 수십만 개라 JSON 객체로는 못 보낸다. 셀 하나당 1바이트로 줄이고 base64로 싣는다: + 하위 6비트(0x3F) = 32방위 코드(0~31, 0=화면 오른쪽·시계방향), 32=제자리, 33=표고 없음 + 최상위 비트(0x80) = 도로 도달(적색). 꺼져 있으면 미도달(백색 화살표). + 바이트 순서는 `grid.row_spans`를 행 → 구간 → 열 오름차순으로 훑은 순서와 같다. + """ + flow = preview.flow + if flow is None or domain is None: + return None + order = np.flatnonzero(domain.reshape(-1)) + analyzed = flow.analyzed[order] + reaches = flow.reaches_road[order] + packed = np.clip(flow.direction[order], 0, AZIMUTH_INVALID).astype(np.uint8) + packed |= np.where(reaches, 0x80, 0).astype(np.uint8) + burned = flow.burned + return { + "encoding": "base64-uint8", + "azimuth_steps": AZIMUTH_STEPS, + "sink_code": AZIMUTH_SINK, + "invalid_code": AZIMUTH_INVALID, + "cells": int(order.size), + "reaches_road": int((reaches & analyzed).sum()), + "no_road": int((~reaches & analyzed).sum()), + # 격자에는 있으나 등고선 TIN 밖이라 표고가 없어 판정 못한 셀. + "unanalyzed": int((~analyzed).sum()), + # 확정된 상류 세류망을 따라 흐름을 강제로 새긴 셀 수. + "burned": 0 if burned is None else int(burned[order].sum()), + "outer_seeds": flow.outer_seeds, + "interior_seeds": flow.interior_seeds, + "data": base64.b64encode(packed.tobytes()).decode("ascii"), + } + + +def _boundary_geometry(ring: list[tuple[float, float]]) -> list[Any]: + """2차 유역 외곽 링을 저장용 폴리곤으로 만든다(정점 3개 미만이면 비운다).""" + return [Polygon(ring)] if len(ring) >= 4 else [] + + +def _as_polygons(geometry: Any) -> list[Any]: + if geometry is None or geometry.is_empty: + return [] + return list(geometry.geoms) if geometry.geom_type == "MultiPolygon" else [geometry] + + +def _grid_bbox_polygon(spec: Any) -> Polygon: + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_min = spec.y_max - spec.n_rows * spec.cell_m + return box(spec.x_min, y_min, x_max, spec.y_max) + + +def _line_lonlat(line: Any, to_lonlat: Any) -> list[list[float]]: + return [list(to_lonlat(x, y)) for x, y in line.coords] + + +def _polygon_rings(geometry: Any, to_lonlat: Any) -> list[list[list[float]]]: + """폴리곤/멀티폴리곤의 외곽 링만 뽑아 lonlat으로 바꾼다.""" + if geometry is None or geometry.is_empty: + return [] + parts = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry] + return [[list(to_lonlat(x, y)) for x, y in part.exterior.coords] for part in parts] + + +def _grid_bbox_lonlat(spec: Any, to_lonlat: Any) -> list[list[float]]: + x_min = spec.x_min + x_max = spec.x_min + spec.n_cols * spec.cell_m + y_max = spec.y_max + y_min = spec.y_max - spec.n_rows * spec.cell_m + corners = ((x_min, y_min), (x_min, y_max), (x_max, y_max), (x_max, y_min), (x_min, y_min)) + return [list(to_lonlat(x, y)) for x, y in corners] -- 2.54.0 From 3ec6527cc227120e65068e75bc40d7a0624c70fc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:34:01 +0900 Subject: [PATCH 072/441] =?UTF-8?q?refactor(B03):=20=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B4=80=ED=95=A8=20=EB=9D=BC=EC=9A=B0=ED=84=B0=20700?= =?UTF-8?q?=EC=A4=84=20=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=B2=AD=ED=81=AC=C2=B7=EB=B3=B4=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 749줄 한 파일을 셋으로 나눔 (동작 불변, 순수 이동). - `B03_FileInput_Router_Temp.py` 478줄 — 묶음 생성·목록·삭제·파일 업로드·프로젝트 연결 - `B03_FileInput_Router_Temp_Chunks.py` 294줄 — 세션·조각·진행·마무리 (prefix 없는 APIRouter 를 본체가 `include_router` 로 붙여 `/api/temp-uploads/...` 경로 불변) - `B03_FileInput_Router_Temp_Support.py` 46줄 — 조각 수·묶음 폴더·상태 갱신 (순환 방지) 검증: 라우트 10개(temp 9 + attach 1) 경로·메서드 동일, 공용 브라우저에서 실제 청크 흐름 create 200 → upload-sessions 200 → chunks 200 → finalize 200 → delete 200, ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Router_Temp.py | 291 +---------------- .../B03_FileInput_Router_Temp_Chunks.py | 294 ++++++++++++++++++ .../B03_FileInput_Router_Temp_Support.py | 46 +++ 3 files changed, 350 insertions(+), 281 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_Router_Temp_Chunks.py create mode 100644 B03_FileInput/B03_FileInput_Router_Temp_Support.py diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index a190a34b..3304a0fe 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -13,53 +13,42 @@ from typing import Any from uuid import UUID, uuid4 import aiomysql -from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi import APIRouter, Depends, File, UploadFile from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Engine import ( - merge_upload_chunks, - remove_chunk_session, - resolve_chunk_session_dir, resolve_upload_destination, - save_upload_chunk, save_upload_stream, ) from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata from B03_FileInput.B03_FileInput_Repository import ( create_input_file, get_project_storage_relative_path, - list_completed_chunk_indexes, - mark_upload_session_completed, - mark_upload_session_failed, supersede_previous_input_files, - upsert_upload_chunk, ) from B03_FileInput.B03_FileInput_Repository_Temp import ( create_temp_batch, - create_temp_upload_session, delete_temp_batch, delete_temp_batch_file, get_temp_batch, get_temp_batch_file, - get_temp_batch_file_types, - get_temp_upload_session, is_batch_required_complete, list_temp_batch_files, list_temp_batch_sessions, list_temp_batches, - mark_temp_batch_completed, - mark_temp_batch_incomplete, mark_temp_batch_linked, upsert_temp_batch_file, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import router as chunk_router +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _POINT_CLOUD_FILE_TYPES, + _batch_root, + _iso, + _refresh_batch_status, +) from B03_FileInput.B03_FileInput_Schema import ( - ChunkSessionCreateRequest, - ChunkSessionCreateResponse, - ChunkUploadResponse, FileUploadDescriptor, - UploadFinalizeRequest, - UploadStatusResponse, ) from B03_FileInput.B03_FileInput_Schema_Temp import ( TempBatchAttachResponse, @@ -87,7 +76,6 @@ from common_util.common_util_workflow_state import ( from config.config_db import get_db_pool from config.config_system import ( TEMP_UPLOAD_RETENTION_DAYS, - UPLOAD_CHUNK_SIZE_BYTES, UPLOAD_MAX_FILES, ) @@ -97,33 +85,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/temp-uploads", tags=["B03 Temp Upload"]) attach_router = APIRouter(prefix="/api/projects", tags=["B03 Temp Upload"]) -_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) - - -def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: - return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) - - -def _iso(value: Any) -> str | None: - return value.isoformat() if value is not None and hasattr(value, "isoformat") else None - - -async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: - """소유권을 확인하고 묶음 폴더를 돌려준다.""" - await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) - return Path(resolve_temp_batch_path(user_id, batch_id)) - - -async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: - """필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다.""" - file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) - complete = is_batch_required_complete(file_types) - if complete: - await mark_temp_batch_completed(connection, batch_id=batch_id) - else: - # 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다. - await mark_temp_batch_incomplete(connection, batch_id=batch_id) - return complete +# 청크 업로드 엔드포인트는 파일이 700줄을 넘어 떼어냈다(2026-09-04) — 경로는 그대로다. +router.include_router(chunk_router) @router.post("", response_model=TempBatchCreateResponse) @@ -367,240 +330,6 @@ async def upload_batch_files( await upload.close() -@router.post("/{batch_id}/upload-sessions", response_model=ChunkSessionCreateResponse) -async def create_batch_upload_session( - batch_id: str, - payload: ChunkSessionCreateRequest, - session: dict[str, Any] = Depends(verify_session), -) -> ChunkSessionCreateResponse | JSONResponse: - """대용량 파일(LAS/LAZ) 청크 세션을 만든다 — 프로젝트 업로드와 같은 규칙.""" - user_id = int(session["user_id"]) - chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) - total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) - session_id = str(uuid4()) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - await _batch_root(connection, batch_id=batch_id, user_id=user_id) - await create_temp_upload_session( - connection, - session_id=session_id, - batch_id=batch_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - await connection.commit() - return ChunkSessionCreateResponse( - project_id=batch_id, - upload_session_id=session_id, - original_filename=payload.original_filename, - file_size_bytes=payload.size_bytes, - chunk_size_bytes=chunk_size_bytes, - total_chunks=total_chunks, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("임시 보관함 청크 세션 생성 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, - ) - - -@router.post("/{batch_id}/chunks", response_model=ChunkUploadResponse) -async def upload_batch_chunk( - batch_id: str, - session_id: str = Form(...), - chunk_index: int = Form(...), - chunk_data: UploadFile = File(...), - session: dict[str, Any] = Depends(verify_session), -) -> ChunkUploadResponse | JSONResponse: - """청크 한 조각을 보관함 묶음 폴더에 저장한다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=session_id - ) - if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, - ) - session_dir = resolve_chunk_session_dir(batch_root, session_id) - chunk_path, size_bytes, chunk_hash = await save_upload_chunk( - chunk_data, - session_dir, - chunk_index, - expected_max_bytes=int(upload_session["chunk_size_bytes"]), - ) - completed_chunks = await upsert_upload_chunk( - connection, - session_id=session_id, - chunk_index=chunk_index, - chunk_hash=chunk_hash, - size_bytes=size_bytes, - stored_at=chunk_path.relative_to(batch_root).as_posix(), - ) - return ChunkUploadResponse( - upload_session_id=session_id, - chunk_index=chunk_index, - completed_chunks=completed_chunks, - total_chunks=int(upload_session["total_chunks"]), - chunk_hash=chunk_hash, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("임시 보관함 청크 업로드 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, - ) - finally: - await chunk_data.close() - - -@router.get("/{batch_id}/upload-status/{session_id}", response_model=UploadStatusResponse) -async def get_batch_upload_status( - batch_id: str, - session_id: str, - session: dict[str, Any] = Depends(verify_session), -) -> UploadStatusResponse | JSONResponse: - """이어올리기용 — 이미 올라간 청크 번호를 돌려준다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - try: - async with pool.acquire() as connection: - await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=session_id - ) - completed_indexes = await list_completed_chunk_indexes( - connection, session_id=session_id - ) - return UploadStatusResponse( - upload_session_id=session_id, - upload_status=str(upload_session["status"]), - original_filename=str(upload_session["original_filename"]), - file_size_bytes=int(upload_session["file_size_bytes"]), - chunk_size_bytes=int(upload_session["chunk_size_bytes"]), - total_chunks=int(upload_session["total_chunks"]), - completed_chunks=len(completed_indexes), - completed_chunk_indexes=completed_indexes, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except Exception: - logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, - ) - - -@router.post("/{batch_id}/finalize", response_model=TempFileUploadResponse) -async def finalize_batch_upload( - batch_id: str, - payload: UploadFinalizeRequest, - session: dict[str, Any] = Depends(verify_session), -) -> TempFileUploadResponse | JSONResponse: - """청크를 병합해 보관함에 저장하고, 필수 파일이 다 차면 완료로 올린다.""" - user_id = int(session["user_id"]) - pool = get_db_pool() - final_path: Path | None = None - try: - async with pool.acquire() as connection: - batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) - upload_session = await get_temp_upload_session( - connection, batch_id=batch_id, session_id=payload.session_id - ) - if int(upload_session["total_chunks"]) != payload.total_chunks: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, - ) - completed_indexes = await list_completed_chunk_indexes( - connection, session_id=payload.session_id - ) - if completed_indexes != list(range(payload.total_chunks)): - return JSONResponse( - status_code=400, - content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, - ) - - descriptor = FileUploadDescriptor( - original_filename=str(upload_session["original_filename"]), - size_bytes=int(upload_session["file_size_bytes"]), - ) - final_path = merge_upload_chunks( - batch_root, descriptor, payload.session_id, payload.total_chunks - ) - metadata = await asyncio.to_thread(analyze_input_metadata, final_path) - relative_path = final_path.relative_to(batch_root).as_posix() - file_type = final_path.suffix.lower().lstrip(".") - crs_epsg = metadata.get("epsg") - - await connection.begin() - try: - await upsert_temp_batch_file( - connection, - batch_id=batch_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - file_size_bytes=int(upload_session["file_size_bytes"]), - crs_epsg=int(crs_epsg) if crs_epsg is not None else None, - metadata=metadata, - ) - await mark_upload_session_completed(connection, session_id=payload.session_id) - required_complete = await _refresh_batch_status(connection, batch_id=batch_id) - await connection.commit() - except Exception: - await connection.rollback() - await mark_upload_session_failed(connection, session_id=payload.session_id) - raise - - remove_chunk_session(batch_root, payload.session_id) - return TempFileUploadResponse( - batch_id=batch_id, - files=[ - TempFileUploadResult( - batch_id=batch_id, - file_type=file_type, - original_filename=descriptor.original_filename, - relative_path=relative_path, - size_bytes=int(upload_session["file_size_bytes"]), - metadata=metadata, - ) - ], - required_complete=required_complete, - ) - except LookupError as exc: - return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) - except (OSError, ValueError) as exc: - if final_path is not None: - final_path.unlink(missing_ok=True) - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - if final_path is not None: - final_path.unlink(missing_ok=True) - logger.exception("임시 보관함 병합 실패: batch_id=%s", batch_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, - ) - - @attach_router.post( "/{project_id}/temp-uploads/{batch_id}/attach", response_model=TempBatchAttachResponse ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py b/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py new file mode 100644 index 00000000..20990d92 --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Temp_Chunks.py @@ -0,0 +1,294 @@ +"""임시 보관함 청크 업로드 엔드포인트 — 세션 생성·조각 전송·진행 조회·마무리. + +라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 `include_router` 로 붙이므로 +여기 라우터에는 prefix 를 두지 않는다 — 두면 `/api/temp-uploads` 가 두 번 붙는다. +""" + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Engine import ( + merge_upload_chunks, + remove_chunk_session, + resolve_chunk_session_dir, + save_upload_chunk, +) +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + list_completed_chunk_indexes, + mark_upload_session_completed, + mark_upload_session_failed, + upsert_upload_chunk, +) +from B03_FileInput.B03_FileInput_Repository_Temp import ( + create_temp_upload_session, + get_temp_batch, + get_temp_upload_session, + upsert_temp_batch_file, +) +from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _batch_root, + _refresh_batch_status, + _total_chunks, +) +from B03_FileInput.B03_FileInput_Schema import ( + ChunkSessionCreateRequest, + ChunkSessionCreateResponse, + ChunkUploadResponse, + FileUploadDescriptor, + UploadFinalizeRequest, + UploadStatusResponse, +) +from B03_FileInput.B03_FileInput_Schema_Temp import ( + TempFileUploadResponse, + TempFileUploadResult, +) +from common_util.common_util_auth import verify_session +from config.config_db import get_db_pool +from config.config_system import ( + UPLOAD_CHUNK_SIZE_BYTES, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["B03 Temp Upload"]) + + +@router.post("/{batch_id}/upload-sessions", response_model=ChunkSessionCreateResponse) +async def create_batch_upload_session( + batch_id: str, + payload: ChunkSessionCreateRequest, + session: dict[str, Any] = Depends(verify_session), +) -> ChunkSessionCreateResponse | JSONResponse: + """대용량 파일(LAS/LAZ) 청크 세션을 만든다 — 프로젝트 업로드와 같은 규칙.""" + user_id = int(session["user_id"]) + chunk_size_bytes = min(payload.chunk_size_bytes, UPLOAD_CHUNK_SIZE_BYTES) + total_chunks = _total_chunks(payload.size_bytes, chunk_size_bytes) + session_id = str(uuid4()) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await _batch_root(connection, batch_id=batch_id, user_id=user_id) + await create_temp_upload_session( + connection, + session_id=session_id, + batch_id=batch_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + await connection.commit() + return ChunkSessionCreateResponse( + project_id=batch_id, + upload_session_id=session_id, + original_filename=payload.original_filename, + file_size_bytes=payload.size_bytes, + chunk_size_bytes=chunk_size_bytes, + total_chunks=total_chunks, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 세션 생성 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 세션 생성 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/chunks", response_model=ChunkUploadResponse) +async def upload_batch_chunk( + batch_id: str, + session_id: str = Form(...), + chunk_index: int = Form(...), + chunk_data: UploadFile = File(...), + session: dict[str, Any] = Depends(verify_session), +) -> ChunkUploadResponse | JSONResponse: + """청크 한 조각을 보관함 묶음 폴더에 저장한다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + if chunk_index < 0 or chunk_index >= int(upload_session["total_chunks"]): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "청크 인덱스가 범위를 벗어났습니다."}, + ) + session_dir = resolve_chunk_session_dir(batch_root, session_id) + chunk_path, size_bytes, chunk_hash = await save_upload_chunk( + chunk_data, + session_dir, + chunk_index, + expected_max_bytes=int(upload_session["chunk_size_bytes"]), + ) + completed_chunks = await upsert_upload_chunk( + connection, + session_id=session_id, + chunk_index=chunk_index, + chunk_hash=chunk_hash, + size_bytes=size_bytes, + stored_at=chunk_path.relative_to(batch_root).as_posix(), + ) + return ChunkUploadResponse( + upload_session_id=session_id, + chunk_index=chunk_index, + completed_chunks=completed_chunks, + total_chunks=int(upload_session["total_chunks"]), + chunk_hash=chunk_hash, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("임시 보관함 청크 업로드 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "청크 업로드 중 오류가 발생했습니다."}, + ) + finally: + await chunk_data.close() + + +@router.get("/{batch_id}/upload-status/{session_id}", response_model=UploadStatusResponse) +async def get_batch_upload_status( + batch_id: str, + session_id: str, + session: dict[str, Any] = Depends(verify_session), +) -> UploadStatusResponse | JSONResponse: + """이어올리기용 — 이미 올라간 청크 번호를 돌려준다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + try: + async with pool.acquire() as connection: + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=session_id + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=session_id + ) + return UploadStatusResponse( + upload_session_id=session_id, + upload_status=str(upload_session["status"]), + original_filename=str(upload_session["original_filename"]), + file_size_bytes=int(upload_session["file_size_bytes"]), + chunk_size_bytes=int(upload_session["chunk_size_bytes"]), + total_chunks=int(upload_session["total_chunks"]), + completed_chunks=len(completed_indexes), + completed_chunk_indexes=completed_indexes, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except Exception: + logger.exception("임시 보관함 업로드 상태 조회 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 상태 조회 중 오류가 발생했습니다."}, + ) + + +@router.post("/{batch_id}/finalize", response_model=TempFileUploadResponse) +async def finalize_batch_upload( + batch_id: str, + payload: UploadFinalizeRequest, + session: dict[str, Any] = Depends(verify_session), +) -> TempFileUploadResponse | JSONResponse: + """청크를 병합해 보관함에 저장하고, 필수 파일이 다 차면 완료로 올린다.""" + user_id = int(session["user_id"]) + pool = get_db_pool() + final_path: Path | None = None + try: + async with pool.acquire() as connection: + batch_root = await _batch_root(connection, batch_id=batch_id, user_id=user_id) + upload_session = await get_temp_upload_session( + connection, batch_id=batch_id, session_id=payload.session_id + ) + if int(upload_session["total_chunks"]) != payload.total_chunks: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "세션 청크 개수가 일치하지 않습니다."}, + ) + completed_indexes = await list_completed_chunk_indexes( + connection, session_id=payload.session_id + ) + if completed_indexes != list(range(payload.total_chunks)): + return JSONResponse( + status_code=400, + content={"status": "error", "message": "아직 업로드되지 않은 청크가 있습니다."}, + ) + + descriptor = FileUploadDescriptor( + original_filename=str(upload_session["original_filename"]), + size_bytes=int(upload_session["file_size_bytes"]), + ) + final_path = merge_upload_chunks( + batch_root, descriptor, payload.session_id, payload.total_chunks + ) + metadata = await asyncio.to_thread(analyze_input_metadata, final_path) + relative_path = final_path.relative_to(batch_root).as_posix() + file_type = final_path.suffix.lower().lstrip(".") + crs_epsg = metadata.get("epsg") + + await connection.begin() + try: + await upsert_temp_batch_file( + connection, + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + file_size_bytes=int(upload_session["file_size_bytes"]), + crs_epsg=int(crs_epsg) if crs_epsg is not None else None, + metadata=metadata, + ) + await mark_upload_session_completed(connection, session_id=payload.session_id) + required_complete = await _refresh_batch_status(connection, batch_id=batch_id) + await connection.commit() + except Exception: + await connection.rollback() + await mark_upload_session_failed(connection, session_id=payload.session_id) + raise + + remove_chunk_session(batch_root, payload.session_id) + return TempFileUploadResponse( + batch_id=batch_id, + files=[ + TempFileUploadResult( + batch_id=batch_id, + file_type=file_type, + original_filename=descriptor.original_filename, + relative_path=relative_path, + size_bytes=int(upload_session["file_size_bytes"]), + metadata=metadata, + ) + ], + required_complete=required_complete, + ) + except LookupError as exc: + return lookup_error_response(exc, logger, context="B03 보관함", batch_id=batch_id) + except (OSError, ValueError) as exc: + if final_path is not None: + final_path.unlink(missing_ok=True) + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + if final_path is not None: + final_path.unlink(missing_ok=True) + logger.exception("임시 보관함 병합 실패: batch_id=%s", batch_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "업로드 최종 처리 중 오류가 발생했습니다."}, + ) diff --git a/B03_FileInput/B03_FileInput_Router_Temp_Support.py b/B03_FileInput/B03_FileInput_Router_Temp_Support.py new file mode 100644 index 00000000..30e7477a --- /dev/null +++ b/B03_FileInput/B03_FileInput_Router_Temp_Support.py @@ -0,0 +1,46 @@ +"""임시 보관함 라우터 보조 — 조각 수 계산·묶음 폴더 확인·상태 갱신. + +라우터가 700줄을 넘어 떼어냈다(2026-09-04). 본체와 청크 모듈이 함께 쓰는 것만 둔다. +""" + +from pathlib import Path +from typing import Any + +from B03_FileInput.B03_FileInput_Repository_Temp import ( + get_temp_batch, + get_temp_batch_file_types, + is_batch_required_complete, + mark_temp_batch_completed, + mark_temp_batch_incomplete, +) +from common_util.common_util_storage import ( + resolve_temp_batch_path, +) + +_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"}) + + +def _total_chunks(size_bytes: int, chunk_size_bytes: int) -> int: + return max(1, (size_bytes + chunk_size_bytes - 1) // chunk_size_bytes) + + +def _iso(value: Any) -> str | None: + return value.isoformat() if value is not None and hasattr(value, "isoformat") else None + + +async def _batch_root(connection: Any, *, batch_id: str, user_id: int) -> Path: + """소유권을 확인하고 묶음 폴더를 돌려준다.""" + await get_temp_batch(connection, batch_id=batch_id, user_id=user_id) + return Path(resolve_temp_batch_path(user_id, batch_id)) + + +async def _refresh_batch_status(connection: Any, *, batch_id: str) -> bool: + """필수 파일 충족 여부에 맞춰 상태를 맞춘다. 완료 여부를 돌려준다.""" + file_types = await get_temp_batch_file_types(connection, batch_id=batch_id) + complete = is_batch_required_complete(file_types) + if complete: + await mark_temp_batch_completed(connection, batch_id=batch_id) + else: + # 파일을 지워 필수 조건이 깨진 경우 — 프로젝트 연결 대상에서 빠져야 한다. + await mark_temp_batch_incomplete(connection, batch_id=batch_id) + return complete -- 2.54.0 From 8be60007e762644b0dc6cb4ccb98775c5e5edf16 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:34:18 +0900 Subject: [PATCH 073/441] =?UTF-8?q?refactor(B07):=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EC=A1=B0=EB=A6=BD=20=EC=A7=80=EC=9B=90=20700=EC=A4=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=EB=8F=84=C2=B7=EC=9E=85=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 808줄 한 파일을 셋으로 나눔 (동작 불변, 순수 이동). - `B07_DesignDetail_Router_Support.py` 532줄 — 도면 목록·장 배치·확정 저장·재계산 - `B07_DesignDetail_Router_Support_Basin.py` 279줄 — 유역도 배경(GeoJSON·좌표 환산·도곽 클리핑) - `B07_DesignDetail_Router_Support_Io.py` 88줄 — 원본 JSON·매니페스트 입출력, 측점 대응표 떼어낸 `watershed_source`·`clip_line_to_box` 는 본체에서 `as` 로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 import 경로가 그대로임. 검증: 라우트 7개 그대로, 공용 브라우저 `design-drawings` 200 · `frame-template` 200, ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Router_Support.py | 314 ++---------------- .../B07_DesignDetail_Router_Support_Basin.py | 279 ++++++++++++++++ .../B07_DesignDetail_Router_Support_Io.py | 88 +++++ 3 files changed, 386 insertions(+), 295 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 6d69433e..bc045ce5 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -5,15 +5,10 @@ import json import logging -import math import re from pathlib import Path from typing import Any -from pyproj import Transformer - -from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE -from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files from B06_Section.B06_Section_Engine_Design import compute_cross_design from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, @@ -21,7 +16,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( infer_station_interval, station_no_label, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing, map_area_mm +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import ( build_blank_drawing, build_cover_drawing, @@ -41,15 +36,31 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( QUANTITY_VALUE_KEYS, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields + +# 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04). +# 여기서 그대로 다시 내보내 호출부(`B07_DesignDetail_Router.py`)의 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 ( + watershed_source as watershed_source, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( + _cross_files, + _design_root, + _read_json, + _read_manifest, + _station_map, + _write_manifest, +) from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingItem, ) -from common_util.common_util_drainage_pipes import detail_basins_path from common_util.common_util_route_profile import design_elevation_from_longitudinal -from config.config_system import DRAWING_SCALE_BASIN logger = logging.getLogger(__name__) + _STAGE_DIR = "B07_DesignDetail" _CROSS_ID = re.compile(r"^cross_(\d+)m$") @@ -73,293 +84,6 @@ BLANK_DRAWINGS: tuple[tuple[str, str], ...] = ( BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS) -def _read_json(path: Path) -> dict[str, Any]: - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") - return payload - - -def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: - cross_dir = longitudinal_path.parent.parent / "cross_sections" - if not cross_dir.is_dir(): - raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") - stations = longitudinal.get("stations") - valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) - files = sorted(cross_dir.glob("cross_*.json")) - if valid_names: - files = [path for path in files if path.name in valid_names] - return files - - -def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: - stations = longitudinal.get("stations", []) - if not isinstance(stations, list): - return {} - return { - round(float(station.get("chainage_m", 0))): station - for station in stations - if isinstance(station, dict) - } - - -def _design_root(project_root: Path) -> Path: - return project_root / _STAGE_DIR - - -def _read_manifest(project_root: Path) -> dict[str, Any]: - path = _design_root(project_root) / "manifest.json" - if not path.is_file(): - return {"drawings": {}} - payload = _read_json(path) - return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} - - -def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: - stage_root = _design_root(project_root) - stage_root.mkdir(parents=True, exist_ok=True) - path = stage_root / "manifest.json" - temporary = path.with_suffix(".tmp") - temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") - temporary.replace(path) - - -def _geojson_payload(path: Path) -> dict[str, Any]: - """GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict.""" - if not path.is_file(): - return {} - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, ValueError): - logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path) - return {} - return payload if isinstance(payload, dict) else {} - - -def _geojson_features(path: Path) -> list[dict[str, Any]]: - """GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록.""" - features = _geojson_payload(path).get("features") - return features if isinstance(features, list) else [] - - -def _basins_crs(context: Any, payload: dict[str, Any]) -> str: - """저장본을 미터로 되돌릴 좌표계. - - 저장할 때 쓴 좌표계를 파일이 갖고 있으면 그 값이다. 없으면 좌표계 기록 이전에 저장된 - 파일이라 노선 CSV의 EPSG 라벨로 쓰였다 — 그 라벨로 되돌려야 왕복이 맞는다 - (2026-09-01). 라벨을 못 읽으면 현재 사업지 좌표계로 둔다. - """ - from common_util.common_util_route_geometry import ( - find_planned_route_file, - read_planned_route, - ) - - stored = payload.get("crs_input") - if isinstance(stored, str) and stored: - return stored - route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input") - planned = read_planned_route(route_file) if route_file else None - if planned is not None and planned.epsg: - logger.warning( - "B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. " - "B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.", - planned.epsg, - ) - return f"EPSG:{planned.epsg}" - return context.crs - - -def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: - """LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" - if not isinstance(geometry, dict): - return [] - kind = geometry.get("type") - coordinates = geometry.get("coordinates") - if not isinstance(coordinates, list) or not coordinates: - return [] - if kind == "LineString": - return [[(float(p[0]), float(p[1])) for p in coordinates if isinstance(p, list)]] - if kind in ("MultiLineString", "Polygon"): - return [ - [(float(p[0]), float(p[1])) for p in part if isinstance(p, list)] - for part in coordinates - if isinstance(part, list) - ] - return [] - - -def _clip_segment( - start: tuple[float, float], - end: tuple[float, float], - box: tuple[float, float, float, float], -) -> tuple[tuple[float, float], tuple[float, float]] | None: - """선분에서 상자 안에 드는 부분만 돌려준다(Liang-Barsky). 겹치지 않으면 None.""" - min_x, min_y, max_x, max_y = box - dx = end[0] - start[0] - dy = end[1] - start[1] - t0, t1 = 0.0, 1.0 - for numerator, denominator in ( - (start[0] - min_x, -dx), - (max_x - start[0], dx), - (start[1] - min_y, -dy), - (max_y - start[1], dy), - ): - if denominator == 0.0: - if numerator < 0.0: - return None # 경계와 나란한데 바깥이다 - continue - t = numerator / denominator - if denominator < 0.0: - if t > t1: - return None - t0 = max(t0, t) - else: - if t < t0: - return None - t1 = min(t1, t) - return ( - (start[0] + t0 * dx, start[1] + t0 * dy), - (start[0] + t1 * dx, start[1] + t1 * dy), - ) - - -def clip_line_to_box( - line: list[tuple[float, float]], box: tuple[float, float, float, float] -) -> list[list[tuple[float, float]]]: - """범위 안에 든 부분만 조각으로 잘라 낸다 — 끝점은 경계 위에 정확히 놓인다. - - 도엽 등고선 한 줄은 도엽 끝까지 이어지므로, 걸치면 통째로 남기면 도면이 A1을 넘는다. - 예전 구현은 경계 바깥 점을 하나씩 물고 나와 외곽선이 들쭉날쭉했다(2026-08-31 사용자 - 지적). 이제 교차점을 계산해 자르므로 절취면이 곧게 떨어진다. - """ - runs: list[list[tuple[float, float]]] = [] - current: list[tuple[float, float]] = [] - for start, end in zip(line, line[1:]): - piece = _clip_segment(start, end, box) - if piece is None: - current = [] - continue - head, tail = piece - if head == tail: - continue # 경계에 점으로만 닿았다 - if current and current[-1] == head: - current.append(tail) - else: - current = [head, tail] - runs.append(current) - return [run for run in runs if len(run) >= 2] - - -# 세부유역이 노선에서 이만큼 넘게 떨어져 있으면 좌표계를 잘못 되돌린 것으로 본다. -# 임도 한 노선이 담는 유역은 길어야 수 km라 오검출 여지가 없다. -_BASIN_MAX_DISTANCE_M = 50_000.0 - - -def _too_far_from_route( - ring: list[tuple[float, float]], route_xy: list[tuple[float, float]] -) -> bool: - """되돌린 유역이 노선 근처에 없으면 True — 좌표계를 되찾지 못한 저장본이다.""" - if not ring or not route_xy: - return False - cx = sum(x for x, _ in ring) / len(ring) - cy = sum(y for _, y in ring) / len(ring) - return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M - - -def watershed_source(context: Any) -> dict[str, Any]: - """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. - - 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). - - 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — - 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은 - 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 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)) - - route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] - basins: list[dict[str, Any]] = [] - dropped = 0 - for feature in basins_payload.get("features") or []: - properties = feature.get("properties") or {} - if properties.get("kind") != "detail_basin": - continue - rings = _geometry_lines(feature.get("geometry")) - if not rings: - continue - ring = [basin_metric(point) for point in rings[0]] - # 저장본 좌표계를 못 되찾으면 유역이 노선에서 수백 km 밖으로 떨어진다. 그대로 두면 - # 도곽이 그 거리까지 벌어져 도면이 통째로 빈 화면이 된다 — 유역만 버리고 배경·노선은 - # 그린다(2026-09-01 다른 PC 보고: 유역도 그림 자체가 없음). - if _too_far_from_route(ring, route_xy): - dropped += 1 - continue - basins.append({"ring": ring, "props": properties}) - if dropped: - logger.warning( - "B07 유역도: 노선에서 %.0fkm 넘게 떨어진 세부유역 %d개를 뺐습니다 — " - "저장본 좌표계를 되찾지 못했습니다. B04에서 유역을 다시 확정하세요.", - _BASIN_MAX_DISTANCE_M / 1000.0, - dropped, - ) - - # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 - # (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 - - return { - "route_xy": route_xy, - "basins": basins, - "contours": background["contours"], - "streams": background["streams"], - } - - def _drawing_list( project_root: Path, longitudinal_path: Path, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py new file mode 100644 index 00000000..f229cf17 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -0,0 +1,279 @@ +"""B07 유역도 배경 자료 — GeoJSON 읽기·좌표계 환산·도곽 클리핑. + +지원 모듈이 700줄을 넘어 떼어냈다(2026-09-04). 도면 목록·확정 저장은 본체에 남는다. +""" + +import json +import logging +import math +import re +from pathlib import Path +from typing import Any + +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 common_util.common_util_drainage_pipes import detail_basins_path +from config.config_system import DRAWING_SCALE_BASIN + +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) + + +def _geojson_payload(path: Path) -> dict[str, Any]: + """GeoJSON 전체를 읽는다. 파일이 없거나 깨졌으면 빈 dict.""" + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + logger.warning("B07 유역도: GeoJSON을 읽지 못했습니다 — %s", path) + return {} + return payload if isinstance(payload, dict) else {} + + +def _geojson_features(path: Path) -> list[dict[str, Any]]: + """GeoJSON 피처 목록만 읽는다. 파일이 없거나 깨졌으면 빈 목록.""" + features = _geojson_payload(path).get("features") + return features if isinstance(features, list) else [] + + +def _basins_crs(context: Any, payload: dict[str, Any]) -> str: + """저장본을 미터로 되돌릴 좌표계. + + 저장할 때 쓴 좌표계를 파일이 갖고 있으면 그 값이다. 없으면 좌표계 기록 이전에 저장된 + 파일이라 노선 CSV의 EPSG 라벨로 쓰였다 — 그 라벨로 되돌려야 왕복이 맞는다 + (2026-09-01). 라벨을 못 읽으면 현재 사업지 좌표계로 둔다. + """ + from common_util.common_util_route_geometry import ( + find_planned_route_file, + read_planned_route, + ) + + stored = payload.get("crs_input") + if isinstance(stored, str) and stored: + return stored + route_file = find_planned_route_file(context.project_root / "B03_FileInput" / "input") + planned = read_planned_route(route_file) if route_file else None + if planned is not None and planned.epsg: + logger.warning( + "B07 유역도: 좌표계 기록이 없는 옛 저장본 — 노선 CSV 라벨 EPSG:%s로 되돌립니다. " + "B04에서 유역을 다시 확정하면 현재 좌표계로 새로 남습니다.", + planned.epsg, + ) + return f"EPSG:{planned.epsg}" + return context.crs + + +def _geometry_lines(geometry: Any) -> list[list[tuple[float, float]]]: + """LineString·MultiLineString·Polygon을 점열 목록으로 편다.""" + if not isinstance(geometry, dict): + return [] + kind = geometry.get("type") + coordinates = geometry.get("coordinates") + if not isinstance(coordinates, list) or not coordinates: + return [] + if kind == "LineString": + return [[(float(p[0]), float(p[1])) for p in coordinates if isinstance(p, list)]] + if kind in ("MultiLineString", "Polygon"): + return [ + [(float(p[0]), float(p[1])) for p in part if isinstance(p, list)] + for part in coordinates + if isinstance(part, list) + ] + return [] + + +def _clip_segment( + start: tuple[float, float], + end: tuple[float, float], + box: tuple[float, float, float, float], +) -> tuple[tuple[float, float], tuple[float, float]] | None: + """선분에서 상자 안에 드는 부분만 돌려준다(Liang-Barsky). 겹치지 않으면 None.""" + min_x, min_y, max_x, max_y = box + dx = end[0] - start[0] + dy = end[1] - start[1] + t0, t1 = 0.0, 1.0 + for numerator, denominator in ( + (start[0] - min_x, -dx), + (max_x - start[0], dx), + (start[1] - min_y, -dy), + (max_y - start[1], dy), + ): + if denominator == 0.0: + if numerator < 0.0: + return None # 경계와 나란한데 바깥이다 + continue + t = numerator / denominator + if denominator < 0.0: + if t > t1: + return None + t0 = max(t0, t) + else: + if t < t0: + return None + t1 = min(t1, t) + return ( + (start[0] + t0 * dx, start[1] + t0 * dy), + (start[0] + t1 * dx, start[1] + t1 * dy), + ) + + +def clip_line_to_box( + line: list[tuple[float, float]], box: tuple[float, float, float, float] +) -> list[list[tuple[float, float]]]: + """범위 안에 든 부분만 조각으로 잘라 낸다 — 끝점은 경계 위에 정확히 놓인다. + + 도엽 등고선 한 줄은 도엽 끝까지 이어지므로, 걸치면 통째로 남기면 도면이 A1을 넘는다. + 예전 구현은 경계 바깥 점을 하나씩 물고 나와 외곽선이 들쭉날쭉했다(2026-08-31 사용자 + 지적). 이제 교차점을 계산해 자르므로 절취면이 곧게 떨어진다. + """ + runs: list[list[tuple[float, float]]] = [] + current: list[tuple[float, float]] = [] + for start, end in zip(line, line[1:]): + piece = _clip_segment(start, end, box) + if piece is None: + current = [] + continue + head, tail = piece + if head == tail: + continue # 경계에 점으로만 닿았다 + if current and current[-1] == head: + current.append(tail) + else: + current = [head, tail] + runs.append(current) + return [run for run in runs if len(run) >= 2] + + +# 세부유역이 노선에서 이만큼 넘게 떨어져 있으면 좌표계를 잘못 되돌린 것으로 본다. +# 임도 한 노선이 담는 유역은 길어야 수 km라 오검출 여지가 없다. +_BASIN_MAX_DISTANCE_M = 50_000.0 + + +def _too_far_from_route( + ring: list[tuple[float, float]], route_xy: list[tuple[float, float]] +) -> bool: + """되돌린 유역이 노선 근처에 없으면 True — 좌표계를 되찾지 못한 저장본이다.""" + if not ring or not route_xy: + return False + cx = sum(x for x, _ in ring) / len(ring) + cy = sum(y for _, y in ring) / len(ring) + return min(math.hypot(cx - x, cy - y) for x, y in route_xy) > _BASIN_MAX_DISTANCE_M + + +def watershed_source(context: Any) -> dict[str, Any]: + """유역도 입력(노선·세부유역·등고선·세류선)을 사업지 CRS(m)로 모은다. + + 저장본은 전부 WGS84라 여기서 미터 좌표로 되돌린다(B04가 저장할 때와 반대 방향). + + 되돌리는 좌표계가 둘이다. **배경(도엽 등고선·세류선)은 사업지 좌표계**로 돌린다 — + 노선(`context.vertices`)이 그 좌표계에 있으므로 같은 자리에 겹쳐야 한다. **세부유역은 + 그 파일을 쓸 때 쓴 좌표계**로 돌린다 — 좌표계 기록 이전 저장본은 노선 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)) + + route_xy = [(vertex.x, vertex.y) for vertex in context.vertices] + basins: list[dict[str, Any]] = [] + dropped = 0 + for feature in basins_payload.get("features") or []: + properties = feature.get("properties") or {} + if properties.get("kind") != "detail_basin": + continue + rings = _geometry_lines(feature.get("geometry")) + if not rings: + continue + ring = [basin_metric(point) for point in rings[0]] + # 저장본 좌표계를 못 되찾으면 유역이 노선에서 수백 km 밖으로 떨어진다. 그대로 두면 + # 도곽이 그 거리까지 벌어져 도면이 통째로 빈 화면이 된다 — 유역만 버리고 배경·노선은 + # 그린다(2026-09-01 다른 PC 보고: 유역도 그림 자체가 없음). + if _too_far_from_route(ring, route_xy): + dropped += 1 + continue + basins.append({"ring": ring, "props": properties}) + if dropped: + logger.warning( + "B07 유역도: 노선에서 %.0fkm 넘게 떨어진 세부유역 %d개를 뺐습니다 — " + "저장본 좌표계를 되찾지 못했습니다. B04에서 유역을 다시 확정하세요.", + _BASIN_MAX_DISTANCE_M / 1000.0, + dropped, + ) + + # 배경(등고선·세류선)은 **여러 도엽을 합쳐 받은 뒤 도곽 크기로 절취**한다 + # (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 + + return { + "route_xy": route_xy, + "basins": basins, + "contours": background["contours"], + "streams": background["streams"], + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py new file mode 100644 index 00000000..b4ebb452 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Io.py @@ -0,0 +1,88 @@ +"""B07 도면 조립 지원 — 원본 JSON·매니페스트 읽기/쓰기와 측점 대응표. + +지원 모듈이 700줄을 넘어 떼어냈다(2026-09-04). 유역도 조각과 본체가 함께 쓴다. +""" + +import json +import logging +import re +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files + +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) + + +def _read_json(path: Path) -> dict[str, Any]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("도면 원본 JSON 형식이 올바르지 않습니다.") + return payload + + +def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[Path]: + cross_dir = longitudinal_path.parent.parent / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.") + stations = longitudinal.get("stations") + valid_names = prune_stale_cross_files(cross_dir, stations if isinstance(stations, list) else []) + files = sorted(cross_dir.glob("cross_*.json")) + if valid_names: + files = [path for path in files if path.name in valid_names] + return files + + +def _station_map(longitudinal: dict[str, Any]) -> dict[int, dict[str, Any]]: + stations = longitudinal.get("stations", []) + if not isinstance(stations, list): + return {} + return { + round(float(station.get("chainage_m", 0))): station + for station in stations + if isinstance(station, dict) + } + + +def _design_root(project_root: Path) -> Path: + return project_root / _STAGE_DIR + + +def _read_manifest(project_root: Path) -> dict[str, Any]: + path = _design_root(project_root) / "manifest.json" + if not path.is_file(): + return {"drawings": {}} + payload = _read_json(path) + return payload if isinstance(payload.get("drawings"), dict) else {"drawings": {}} + + +def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None: + stage_root = _design_root(project_root) + stage_root.mkdir(parents=True, exist_ok=True) + path = stage_root / "manifest.json" + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(path) -- 2.54.0 From 472a6f78493af62928b33d8e0e4c7c0b89595d9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:34:18 +0900 Subject: [PATCH 074/441] =?UTF-8?q?refactor(config):=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EC=84=A4=EC=A0=95=20700=EC=A4=84=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=EC=A7=80=ED=98=95?= =?UTF-8?q?=C2=B7=EC=84=A4=EA=B3=84=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 937줄 한 파일을 셋으로 나눔 (값·이름 불변). - `config_system.py` 255줄 — 서버·로깅·DB·업로드·저장소·인증·CORS - `config_system_terrain.py` 186줄 — 5장·5-2장 지형 분석·지표면 모델 파라미터 - `config_system_design.py` 520줄 — 5-3장~5-6장 경로·유역·종횡단·계획선 파라미터 본체가 두 조각을 `import *` 로 다시 내보내므로 기존 `from config.config_system import …` 호출부는 그대로임. 검증: 분리 전 원본과 대조해 **공개 상수 243개 전부 값 동일**(사라진 것은 설계 모듈 내부에서만 쓰는 `_SCS_SOIL` 하나 — 밑줄 시작이라 원래도 외부 참조 없음), 백엔드 재시작 후 기동 정상·주요 API 6종 200, ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- config/config_system.py | 690 +------------------------------- config/config_system_design.py | 520 ++++++++++++++++++++++++ config/config_system_terrain.py | 186 +++++++++ 3 files changed, 710 insertions(+), 686 deletions(-) create mode 100644 config/config_system_design.py create mode 100644 config/config_system_terrain.py diff --git a/config/config_system.py b/config/config_system.py index 84fdf524..6757cffd 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -108,692 +108,10 @@ SEND_ANALYSIS_COMPLETION_EMAIL = ( AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "True").lower() == "true" # ───────────────────────────────────────────────────────────────────────── -# 5. 지형 분석 알고리즘 파라미터 -# ───────────────────────────────────────────────────────────────────────── -# Trimesh 메쉬 생성 -MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위 -MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) -SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) -SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) -SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) -SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) - -# CSF (Cloth Simulation Filter) 지면 분류 파라미터 -SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) -SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) -SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) -SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) -# 낙하 후 스프링 정착에 쓰는 여유 반복 수 (실제 낙하 구간은 현장 기복에서 계산한다) -SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) -# 낙하+정착 반복 상한 — 기복 1500m(0.3185m/회)까지 커버한다 -SURFACE_CSF_MAX_ITERATIONS = int(os.getenv("SURFACE_CSF_MAX_ITERATIONS", "5000")) -SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" -SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( - os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") -) - -# PMF (Progressive Morphological Filter) 지면 분류 파라미터 -SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) -SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) -SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) -SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) -SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) - -# RANSAC (Local plane fitting) 지면 분류 파라미터 -SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) -SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) -SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) -SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) -SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) - -# LAS 자체 지면분류(class 2) 필터 — 이 비율 이상 분류돼 있을 때만 필터로 제공한다. -# 미분류 LAS는 classification이 전부 0이라 마스크가 비어 버린다. -SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( - os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002") -) -# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. -SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) - -# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). -# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 -# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). -SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) - -# ───────────────────────────────────────────────────────────────────────── -# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) -# ───────────────────────────────────────────────────────────────────────── -# 고를 수 있는 지면 필터 전체 — 관리자가 B04 드롭다운에서 요청할 때 쓰인다. -# 자동 전처리가 미리 만드는 조합이 아니다(그건 SURFACE_AUTO_* 가 정한다). -SURFACE_MODEL_SOURCE_FILTERS = tuple( - os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "classification,grid_min_z,csf,pmf").split(",") -) -# 자동 전처리가 만드는 조합 — 기본 필터 1종 × 아래 표현. 나머지는 관리자 요청 시 만든다. -SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) -# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. -SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "csf") -# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) -SURFACE_MODEL_PRECOMPUTE = tuple( - os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") -) -SURFACE_MODEL_SMOOTHING_METHODS = tuple( - os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") -) -SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) - -# footprint(외곽) 산출 -SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) -SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) -SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) -SURFACE_KEEP_LARGEST_FOOTPRINT = ( - os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" -) -SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) -SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) - -# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) -SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) -SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) -SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) -SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) -SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( - os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") -) -SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( - os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") -) -SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) -SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) -SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) - -# 스무딩 파라미터 -SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) -SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) -SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( - os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") -) -SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( - os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") -) -SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) -SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) - -# 등고선 파라미터 -SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) -SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) - -# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정) -# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값). -SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) -# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). -SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) -# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다 -# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py. -SHEET_SURFACE_METHODS = [ - method.strip() - for method in os.getenv( - "SHEET_SURFACE_METHODS", - "tin_sheet,tin,biharmonic,anudem,multires,laplace", - ).split(",") - if method.strip() -] -# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). -SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") -# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 -# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 -# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). -SHEET_SURFACE_AUTO_METHODS = [ - method.strip() - for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") - if method.strip() -] - -# 일반 사용자 WF1 자동 확정 기본값 -SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") -SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") -SURFACE_CONFIRM_DEFAULT_SMOOTH = ( - os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" -) - - -def build_surface_model_config() -> dict: - """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" - return { - "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), - "precompute": list(SURFACE_MODEL_PRECOMPUTE), - "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), - "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, - "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, - "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, - "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, - "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, - "tile_size_meters": SURFACE_TILE_SIZE_M, - "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, - "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, - "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, - "nurbs_degree": SURFACE_NURBS_DEGREE, - "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, - "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, - "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, - "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, - "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, - "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, - "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, - "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, - "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, - "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, - "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, - "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, - "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, - "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, - } - - -# ───────────────────────────────────────────────────────────────────────── -# 5-3. 경로 설계 파라미터 (B05 WF2) -# ───────────────────────────────────────────────────────────────────────── -# 비용 함수 가중치 -ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) -ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) -ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) -ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) -ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) -ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) - -# 격자·경사·비용 제약 -ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) -ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) -ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) -ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) -ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") -ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) -ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) -# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선 -# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변). -ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0")) -# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. -# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. -ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) -# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 -# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). -ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") - -# 임도 등급별 종단경사 한계 및 최소 곡선반지름 (경로탐색 제약 — 계획선 법정 기준과 별개) -FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} -FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} - -# 대안(정속경사) 파라미터 -ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) -ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) -ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) - -# 지형 skeleton (능선/계곡) 파라미터 -SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) -SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( - os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") -) -SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) -SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( - os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") -) -SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) - - -# ───────────────────────────────────────────────────────────────────────── -# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) -# -# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) -# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 -# 유일한 판정 기준이며, 그 경계가 곧 능선이다. -# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). -# ───────────────────────────────────────────────────────────────────────── -# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. -DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) -# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 -# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. -# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 -# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). -DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) -# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). -DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) -# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). -DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) -# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. -DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) -# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). -DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) -# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 -# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. -DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) -# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다. -# -# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m). -# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면 -# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다. -# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다. -DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) -# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. -DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) -# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. -DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) -# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. -DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) -# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 -# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. -DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) -# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. -DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) -# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. -DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0")) -# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. -DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0")) -# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). -DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) -# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. -# 격자가 1m(DRAINAGE_GRID_SIZE_M)라 경계는 1m 계단이다 — 허용오차를 격자 한 칸에 맞추면 -# 형상 손실이 격자 오차 안에 들어간다. 2.0m는 유역별로 따로 단순화하는 탓에 공유 경계가 -# 크게 어긋났다(2026-09-03 실측, 용화: 중첩 1,043㎡·틈 1,184㎡ → 1.0m에서 243㎡·369㎡, -# 면적오차 1.86%→1.01%, 좌표점 622→1,749). 0.5m 이하는 계단 점을 못 지워 좌표점만 6배로 -# 튄다(10,215). -DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "1.0")) -# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). -DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) -# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_PreProcess/drainage/ 아래에 놓인다. -DRAINAGE_CACHE_DIRNAME = "drainage" -DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" -# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — -# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). -DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" -# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다. -DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" - -# ── 관 매설 지점 편집분 ── -# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다. -# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다. -DRAINAGE_EDITS_DIRNAME = "edits" -DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" - -# ── B05용 평균 흐름 화살표 ── -# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. -DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) -# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. -DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) -# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). -DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) -# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. -DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) -# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. -# 파일명 규칙은 B05_Profile_Engine_Watershed_Export.STAGES가 유일한 정의처다. - -# ── 확률강우량 (map.wamis.go.kr 등우선도 — 제주 전용) ── -# 등우선 서버 원본은 제주도만 커버한다(2026-08-13 실측) — 제주 공사지에서만 쓴다. -# 근거·검증: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 5절, PLAN.md A. -WAMIS_CONTOUR_CACHE_DIR = PROJECT_ROOT / "resources" / "data_rainfall_idf_cache" -# 프로젝트별 결과 파일명. drainage/ 아래에 놓인다. -DRAINAGE_RAINFALL_FILENAME = "rainfall_table.json" - -# ── 확률강우량 (관측소 방식 — 본토, 2026-08-13 사용자 확정 로직) ── -# 최근접 관측소 + 이 반경 안 관측소를 모두 받아 100년/24hr 최대 지점을 선정한다. -WAMIS_STATION_RADIUS_M = float(os.getenv("WAMIS_STATION_RADIUS_M", "10000")) -# 관측소 목록·확률강우량 xlsx의 프로젝트별 보존 폴더명. drainage/ 아래에 놓인다 -# (원천이 갱신되는 자료라 프로젝트 시점 스냅샷 — 전역 공통 캐시 아님). -DRAINAGE_RAINFALL_STATION_DIRNAME = "rainfall_stations" -# 설계빈도 강우강도 산출 방식: "mononobe"(기본, 실무 수리계산서 방식) | "general" -DRAINAGE_RAINFALL_IDF_METHOD = os.getenv("DRAINAGE_RAINFALL_IDF_METHOD", "mononobe") - -# ── 배수 유효직경 계산 계수 (2026-08-05 사용자 확정, 위 규정 조사 문서 6절) ── -# 합리식 유출계수 C. 산악지 준용 0.8 — 필요 시 사용자가 이 값을 고친다. -DRAINAGE_RUNOFF_COEFFICIENT = float(os.getenv("DRAINAGE_RUNOFF_COEFFICIENT", "0.8")) -# 설계유량 배수. 시행규칙 별표2 (가): 최대홍수유출량의 2.0배 이상. -DRAINAGE_DESIGN_FLOW_FACTOR = float(os.getenv("DRAINAGE_DESIGN_FLOW_FACTOR", "2.0")) -# 설계빈도(년). 별표2 (가) 1호: 100년빈도 확률강우량. -DRAINAGE_DESIGN_RETURN_PERIOD_YR = int(os.getenv("DRAINAGE_DESIGN_RETURN_PERIOD_YR", "100")) -# 홍수도달시간 하한(분). 국도건설공사 설계실무요령·도로배수지침 "강우지속기간 5분 원칙". -DRAINAGE_TC_MIN_MINUTES = float(os.getenv("DRAINAGE_TC_MIN_MINUTES", "5.0")) -# 관 경사(도). 시공 중 수시 변경되므로 지형 산출 대신 실무 대푯값 10도로 고정(사용자 제시). -DRAINAGE_PIPE_SLOPE_DEG = float(os.getenv("DRAINAGE_PIPE_SLOPE_DEG", "10.0")) -# Manning 조도계수(파형강관). -DRAINAGE_MANNING_N = float(os.getenv("DRAINAGE_MANNING_N", "0.024")) -# 관내 유속 허용범위(m/s). 도로배수지침 0.8~3.0 — 상한 클램프가 안전측(관이 커진다). -DRAINAGE_VELOCITY_MIN_MS = float(os.getenv("DRAINAGE_VELOCITY_MIN_MS", "0.8")) -DRAINAGE_VELOCITY_MAX_MS = float(os.getenv("DRAINAGE_VELOCITY_MAX_MS", "3.0")) -# 원형관 통수단면 비율. 도로설계요령: 관 단면의 70%만 통수 고려. -DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7")) -# 세월교 검토 문턱(mm). 유효직경이 관 최대 규격(파형강관 D2000)을 넘으면 관이 아니라 -# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단 -# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사). -DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000")) -# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서 -# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로 -# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만). -DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500")) -# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**. -# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다. -DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500) - -# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ── -# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자 -# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구 -# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240° -# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다 -# (original/실무문서/_숨김탭분석.md §6-1·§6-2). -FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017")) -FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10")) - -# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ── -# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다. -STRUCTURE_TYPES = ("배관", "기성막이", "대피로", "기타") -STRUCTURE_DEFAULT_TYPE = "배관" -PIPE_DIAMETERS_MM = { - "이중벽관": (150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), - "삼중벽관": (200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), - "파형강관": ( - 150, - 200, - 250, - 300, - 350, - 400, - 450, - 500, - 600, - 700, - 800, - 900, - 1000, - 1200, - 1350, - 1500, - 1650, - 1800, - 2000, - ), -} -PIPE_DEFAULT_TYPE = "파형강관" -# 자동 지정 기본 관경. 별표2 (나) 예외 하한 800mm(사용자 결정 — 계산값이 더 크면 바로 위 규격). -PIPE_DEFAULT_DIAMETER_MM = int(os.getenv("PIPE_DEFAULT_DIAMETER_MM", "800")) -ESCAPE_ROUTE_WIDTHS_M = (1.5, 2.0, 2.5, 3.0) -ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0 -STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물" - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4. 종횡단 생성 파라미터 (B06 WF3) -# ───────────────────────────────────────────────────────────────────────── -SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) -# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가 -# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m). -# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정. -SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.0")) -SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) -SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) -SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) -SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" -FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정) -# -# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값. -# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장). -# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %. -# -# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과 -# 절·성토 단면적 엔진(B06_Section_Engine_Design)이 모두 여기를 읽으며, -# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다. -# ───────────────────────────────────────────────────────────────────────── -STANDARD_CROSS_SECTION = { - "soil": { - "road_width_m": 3.0, # 노폭(차도) - "shoulder_left_m": 0.5, # 노견(좌) - "shoulder_right_m": 0.5, # 노견(우) - # 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm. - "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, - "cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향) - "fill_slope_ratio": 1.2, # 성토 1:1.2 - "cut_slope_ratio": 1.0, # 절토 1:1.0 - }, - "rock": { # 리핑암·발파암 공유 - "road_width_m": 3.0, - "shoulder_left_m": 0.5, - "shoulder_right_m": 0.5, - # 일반 측구 = 690/300/300mm. - "ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, - # L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택). - "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, - "cross_slope_pct": {"min": 3.0, "max": 3.0}, - "fill_slope_ratio": 1.2, - "cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상) - }, - "paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름 - "road_width_m": 3.0, - "shoulder_left_m": 0.5, - "shoulder_right_m": 0.5, - "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, - "cross_slope_pct": {"min": 1.5, "max": 2.0}, - "fill_slope_ratio": 1.2, - "cut_slope_ratio": 1.0, - # 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능). - "pavement_thickness_m": 0.2, - }, -} -# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m). -STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5 -STANDARD_ROCK_BOUNDARY_STEP_M = 0.1 - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3) -# -# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지). -# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 -# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). -# ───────────────────────────────────────────────────────────────────────── -_SCS_SOIL = STANDARD_CROSS_SECTION["soil"] -# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능. -SECTION_ROADBED_WIDTH_M = float( - os.getenv( - "SECTION_ROADBED_WIDTH_M", - str( - _SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"] - ), - ) -) -SECTION_CARRIAGEWAY_WIDTH_M = float( - os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"])) -) -# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. -SECTION_FILL_SLOPE_RATIO = float( - os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"])) -) -# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. -SECTION_GROUND_TYPE_PRESET = { - "soil": "soil", - "ripping_rock": "rock", - "blasting_rock": "rock", -} -# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. -SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") -SECTION_DITCH_SIDES = ("left", "right") -# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지). -SECTION_DITCH_TYPES = ("standard", "l_type") - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-3. 토량환산계수 (B06 유토곡선) -# -# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률) -# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률) -# -# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다. -# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, -# 내역서에 적용하는 수량은 자연상태로 한다." -# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열) -# -# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨 -# 제안값이며 임도 전용 고시 수치가 아니다. -# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90) -# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단 -# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙 -# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에 -# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다. -# -# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지). -# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절 -# ───────────────────────────────────────────────────────────────────────── -EARTHWORK_CONVERSION_FACTORS = { - "soil": {"loose": 1.25, "compacted": 0.90}, - "ripping_rock": {"loose": 1.35, "compacted": 1.15}, - "blasting_rock": {"loose": 1.60, "compacted": 1.30}, -} - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획) -# -# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` -# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. -# -# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 -# -# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 -# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, -# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. -# 현장·발주처 기준에 따라 달라질 수 있으므로 -# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다. -# -# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프. -# 화면 표기 문구는 하드코딩 금지 규칙에 따라 ui_locales에서 따로 관리한다. -# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md -# ───────────────────────────────────────────────────────────────────────── -EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( - ("free_haul", 20.0), - ("dozer", 70.0), - ("dump_truck", None), -) - - -# ───────────────────────────────────────────────────────────────────────── -# 5-4-2. 자연방토 판정 경사 (B06 유토곡선) -# -# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리. -# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면 -# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다. -# -# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와 -# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다. -# ───────────────────────────────────────────────────────────────────────── -NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 - - -# ───────────────────────────────────────────────────────────────────────── -# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) -# -# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. -# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 -# 등급은 grade_to_design_speed로 간접 매핑한다. -# 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 -# 서로 혼용하지 않는다. -# ───────────────────────────────────────────────────────────────────────── -FOREST_ROAD_PROFILE_CRITERIA = { - # 설계속도(km/h)별 법정 기준 - "design_speed": { - 40: { - "max_grade_pct": {"normal": 7.0, "special": 10.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 450.0, - "min_curve_length_m": 40.0, - }, - 30: { - "max_grade_pct": {"normal": 8.0, "special": 12.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 250.0, - "min_curve_length_m": 30.0, - }, - 20: { - "max_grade_pct": {"normal": 9.0, "special": 14.0}, - "max_reverse_grade_pct": 5.0, - "min_vertical_radius_m": 100.0, - "min_curve_length_m": 20.0, - }, - }, - # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 - # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 - # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 - # 설계속도가 오면 그 값이 우선한다(design_speed_kph). - # trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도 - # branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다. - "grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20}, - # 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3). - "selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "work": (20,)}, - # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 - "paved_exception_grade_pct": 18.0, - # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 - "vertical_curve_skip_delta_pct": 5.0, - # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) - "min_tangent_length_m": 20.0, - # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) - "balance_segment_length_m": None, - # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 - # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. - # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. - "main_direction_monotone_ratio": 0.5, -} -GRADE_TERRAIN_TYPES = ("normal", "special") -# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) -GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") - - -# ───────────────────────────────────────────────────────────────────────── -# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) -# -# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 -# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 -# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. -# -# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 -# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. -# ───────────────────────────────────────────────────────────────────────── -FOREST_ROAD_PROFILE_ALIGNMENT = { - # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) - # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 - # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. - "balance_tolerance_percent": 10.0, - # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). - # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). - # L = R × |기울기 대수차 A(비율)| - # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 - # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 - # (예: 20.0 → R=400m, A=10%일 때 L=40m). - "curve_radius_ratio": 0.40, - # **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.** - # 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면 - # L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다 - # (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다. - # 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다. - # 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만 - # 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시). - # 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다. - "default_curve_length_m": 15.0, - # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. - # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. - "curve_tangent_max_ratio": 0.45, - # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. - # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 - # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 - # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). - "curve_skip_legal_exception": False, - # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). - # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. - "pvi_penalty_m2": 25.0, - # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) - "min_segment_stations": 2, - # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. - "edit_step_m": 0.1, - # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) - "grade_violation_policy": "warn", -} - +# 5장(지형·설계 파라미터)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +# 여기서 그대로 다시 내보내므로 `from config.config_system import …` 호출부는 불변이다. +from config.config_system_design import * # noqa: E402,F401,F403 +from config.config_system_terrain import * # noqa: E402,F401,F403 # ───────────────────────────────────────────────────────────────────────── # 6. 저장소 경로 diff --git a/config/config_system_design.py b/config/config_system_design.py new file mode 100644 index 00000000..881ce871 --- /dev/null +++ b/config/config_system_design.py @@ -0,0 +1,520 @@ +"""경로·종횡단·계획선 설계 파라미터 (5-3장 ~ 5-6장). + +`config_system.py` 가 700줄을 넘어 떼어냈다(2026-09-04). 값·이름은 그대로이고, +`config_system` 이 다시 내보내므로 기존 `from config.config_system import …` 는 불변이다. +""" + +import os +from pathlib import Path + +# 지형 파라미터를 참조하는 값이 있어 함께 가져온다. +from config.config_system_terrain import * # noqa: F403 + +# 저장소 뿌리 — 본체(`config_system`)를 되부르면 순환이라 여기서 같은 식으로 잡는다. +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# ───────────────────────────────────────────────────────────────────────── +# 5-3. 경로 설계 파라미터 (B05 WF2) +# ───────────────────────────────────────────────────────────────────────── +# 비용 함수 가중치 +ROUTE_W_DIST = float(os.getenv("ROUTE_W_DIST", "1.0")) +ROUTE_W_GRADE = float(os.getenv("ROUTE_W_GRADE", "2.0")) +ROUTE_W_SIDE = float(os.getenv("ROUTE_W_SIDE", "1.5")) +ROUTE_W_CURVE = float(os.getenv("ROUTE_W_CURVE", "0.5")) +ROUTE_W_AVOID = float(os.getenv("ROUTE_W_AVOID", "10.0")) +ROUTE_WEIGHT_MAX = float(os.getenv("ROUTE_WEIGHT_MAX", "1000.0")) + +# 격자·경사·비용 제약 +ROUTE_MAX_GRADE = float(os.getenv("ROUTE_MAX_GRADE", "0.14")) +ROUTE_MAX_GRADE_PAVED = float(os.getenv("ROUTE_MAX_GRADE_PAVED", "0.18")) +ROUTE_GRID_RES_M = float(os.getenv("ROUTE_GRID_RES_M", "2.0")) +ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", "25.0")) +ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") +ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) +ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) +# 제어점 쌍이 이 배수×비용면 셀보다 가까우면 격자 탐색 없이 직결한다 — 원청 계획노선 +# 보존 (2026-08-30 사용자 확정. 조밀 기준선은 격자 중간점이 못 끼어들어 평면 불변). +ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", "2.0")) +# 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. +# 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. +ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) +# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 +# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). +ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") + +# 임도 등급별 종단경사 한계 및 최소 곡선반지름 (경로탐색 제약 — 계획선 법정 기준과 별개) +FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} +FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} + +# 대안(정속경사) 파라미터 +ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) +ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) +ROUTE_ALT_GRADE_TOLERANCE = float(os.getenv("ROUTE_ALT_GRADE_TOLERANCE", "0.005")) + +# 지형 skeleton (능선/계곡) 파라미터 +SKELETON_VALLEY_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_VALLEY_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_RIDGE_ACC_THRESHOLD_CELLS = int(os.getenv("SKELETON_RIDGE_ACC_THRESHOLD_CELLS", "500")) +SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS = int( + os.getenv("SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS", "5000") +) +SKELETON_NODE_SPACING_M = float(os.getenv("SKELETON_NODE_SPACING_M", "10.0")) + + +# ───────────────────────────────────────────────────────────────────────── +# 5-3-1. 배수유역 격자 해석 파라미터 (B05 WF2 — 2026-07-31 전면 재설계) +# +# 도엽 등고선 TIN 보간 → 웅덩이 채움 → D8 물 방향 → 도로에서 상류 BFS(포인터 더블링) +# 순서로 유역을 정한다. 능선을 따로 탐지하지 않는다 — 도로로 물이 도달하는지 여부가 +# 유일한 판정 기준이며, 그 경계가 곧 능선이다. +# 라이다 DEM은 노선 주변만 커버해 유역 산정에 부족하므로 쓰지 않는다(사용자 지시). +# ───────────────────────────────────────────────────────────────────────── +# 해석 격자 한 변(m). 작을수록 정밀하나 셀 수가 제곱으로 늘어난다. +DRAINAGE_GRID_SIZE_M = float(os.getenv("DRAINAGE_GRID_SIZE_M", "1.0")) +# 1차 배수유역 반경(m). 도로 교차점 상류로 이어진 세류망과 계획 노선을 각각 이 반경으로 +# 버퍼해 합친 범위가 1차 영역이며, 그 bbox가 해석 격자다. +# 노선 버퍼가 필요한 이유: 세류 교차가 없는 구간의 도로도 격자 안에 있어야 그 구간 사면이 +# 유역으로 잡힌다. 세류망 선정이 정확해진 뒤 다시 포함했다(2026-07-31 사용자 지시). +DRAINAGE_INITIAL_RADIUS_M = float(os.getenv("DRAINAGE_INITIAL_RADIUS_M", "100.0")) +# 활성 셀이 격자 최외곽에 닿았을 때 한 번에 넓히는 폭(m). +DRAINAGE_EXPAND_STEP_M = float(os.getenv("DRAINAGE_EXPAND_STEP_M", "200.0")) +# 확장 반복 상한. 경계 링이 전부 비활성이 되면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_MAX_EXPAND_ROUNDS = int(os.getenv("DRAINAGE_MAX_EXPAND_ROUNDS", "6")) +# 최외곽 적색 셀 주변을 한 회차에 넓히는 폭(m). 좁을수록 유역 경계가 정밀하나 회차가 늘어난다. +DRAINAGE_RED_EXPAND_BAND_M = float(os.getenv("DRAINAGE_RED_EXPAND_BAND_M", "50.0")) +# 적색 확장 반복 상한. 새로 추가한 셀에 적색이 없으면 그 전에 스스로 멈춘다(안전핀). +DRAINAGE_RED_EXPAND_MAX_ROUNDS = int(os.getenv("DRAINAGE_RED_EXPAND_MAX_ROUNDS", "20")) +# 격자 셀 수 권장 상한. 넘으면 **경고만** 남기고 그대로 계산한다 — 격자 크기 자동 조절은 +# 하지 않는다(2026-07-31 사용자 지시). 느리면 위 DRAINAGE_GRID_SIZE_M을 직접 올린다. +DRAINAGE_MAX_GRID_CELLS = int(os.getenv("DRAINAGE_MAX_GRID_CELLS", "16000000")) +# 해석용 **도로 굽기 폭**(m) — 설계 도로폭이 아니다. +# +# 설계 도로폭은 등급별로 따로 있다(B05 `/sections/road-widths`: 간선 3.0 / 지선 3.0 / 작업로 2.5m). +# 여기 값은 노선을 격자에 구울 때만 쓰는 해석 파라미터다. 1m 격자에서 도로를 1셀 선으로 구우면 +# D8 대각 이동이 도로를 건너뛰어 상류 물이 도로를 지나쳐 버리므로, 3셀(=3m) 이상 두께가 필요하다. +# 그래서 설계폭과 연동하지 않고 4m로 둔다(2026-08-01 사용자 확인). 값을 바꾸면 재분석해야 한다. +DRAINAGE_ROAD_WIDTH_M = float(os.getenv("DRAINAGE_ROAD_WIDTH_M", "4.0")) +# 계획선 최저점보다 이만큼 아래인 등고선은 상류 기여가 불가능하므로 보간에서 제외한다. +DRAINAGE_CONTOUR_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_MARGIN_M", "10.0")) +# 이보다 짧은 등고선 파편은 노이즈로 보고 버린다. 봉우리 폐합 등고선은 이 값 이상이면 남는다. +DRAINAGE_CONTOUR_MIN_LENGTH_M = float(os.getenv("DRAINAGE_CONTOUR_MIN_LENGTH_M", "20.0")) +# 등고선 정점 재샘플 간격(m). 조밀할수록 TIN이 정확하나 Delaunay 비용이 커진다. +DRAINAGE_CONTOUR_RESAMPLE_M = float(os.getenv("DRAINAGE_CONTOUR_RESAMPLE_M", "5.0")) +# TIN 삼각망을 만들 때 격자 범위 밖으로 남길 여유(m). 도엽 전체 등고선을 다 물면 삼각망 +# 비용만 커지고 결과는 같다. 여유가 0이면 격자 가장자리가 TIN 밖으로 나가 NaN이 된다. +DRAINAGE_CONTOUR_CLIP_MARGIN_M = float(os.getenv("DRAINAGE_CONTOUR_CLIP_MARGIN_M", "100.0")) +# 평탄면 해소용 미세 경사(m/셀). 채움 후 흐름 방향이 없는 셀에 출구 쪽 경사를 만들어 준다. +DRAINAGE_FLAT_EPSILON_M = float(os.getenv("DRAINAGE_FLAT_EPSILON_M", "0.001")) +# 관 매설 최대 간격(m). 이 간격을 넘으면 흐름 강도가 가장 큰 지점에 관을 보충한다. +DRAINAGE_PIPE_MAX_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MAX_SPACING_M", "100.0")) +# 관끼리 이보다 가까우면 같은 계곡으로 보고 하나로 합친다. +DRAINAGE_PIPE_MIN_SPACING_M = float(os.getenv("DRAINAGE_PIPE_MIN_SPACING_M", "5.0")) +# 측구 흐름(도로 셀 → 담당 관) 판정용 종단 계획선 샘플 간격(m). +DRAINAGE_DITCH_SAMPLE_M = float(os.getenv("DRAINAGE_DITCH_SAMPLE_M", "1.0")) +# 유역 폴리곤 단순화 허용오차(m). 격자 계단 경계를 매끄럽게 줄여 응답 크기를 낮춘다. +# 격자가 1m(DRAINAGE_GRID_SIZE_M)라 경계는 1m 계단이다 — 허용오차를 격자 한 칸에 맞추면 +# 형상 손실이 격자 오차 안에 들어간다. 2.0m는 유역별로 따로 단순화하는 탓에 공유 경계가 +# 크게 어긋났다(2026-09-03 실측, 용화: 중첩 1,043㎡·틈 1,184㎡ → 1.0m에서 243㎡·369㎡, +# 면적오차 1.86%→1.01%, 좌표점 622→1,749). 0.5m 이하는 계단 점을 못 지워 좌표점만 6배로 +# 튄다(10,215). +DRAINAGE_POLYGON_SIMPLIFY_M = float(os.getenv("DRAINAGE_POLYGON_SIMPLIFY_M", "1.0")) +# 이 면적(㎡) 미만의 유역 조각은 버린다(격자 노이즈 제거). +DRAINAGE_MIN_BASIN_AREA_M2 = float(os.getenv("DRAINAGE_MIN_BASIN_AREA_M2", "100.0")) +# 격자 해석 결과 캐시 파일명. 프로젝트 저장소의 B04_PreProcess/drainage/ 아래에 놓인다. +DRAINAGE_CACHE_DIRNAME = "drainage" +DRAINAGE_CACHE_FILENAME = "watershed_grid.npz" +# 분석 응답 자체를 그대로 담아 두는 파일. 재산정하지 않는 한 이걸 그대로 돌려준다 — +# 저장 배열에서 응답을 다시 조립하면 원본과 어긋날 여지가 생긴다(2026-07-31 사용자 지시). +DRAINAGE_RESPONSE_FILENAME = "00_watershed_response.json" +# 세부유역 산출물. 관 목록이 정해져야 나오므로 해석 산출물(01~03)과 번호를 이어 붙인다. +DRAINAGE_DETAIL_FILENAME = "04_detailed_basins.geojson" + +# ── 관 매설 지점 편집분 ── +# 해석 산출물(01~03)은 다시 돌리면 덮어써도 되지만 사용자가 찍은 관은 그러면 안 된다. +# 같은 폴더 아래 편집분 전용 칸을 따로 두고 B04(관리자)·B05(사용자)가 같은 파일을 본다. +DRAINAGE_EDITS_DIRNAME = "edits" +DRAINAGE_PIPE_POINTS_FILENAME = "pipe_points.json" + +# ── B05용 평균 흐름 화살표 ── +# 셀 화살표는 1m라 축소하면 경향이 안 보인다. 이 크기의 블록으로 묶어 방향을 평균한다. +DRAINAGE_ARROW_BLOCK_M = float(os.getenv("DRAINAGE_ARROW_BLOCK_M", "10.0")) +# 화살표끼리 최소 이 간격을 두고 솎아낸다. 촘촘하면 도면이 지저분해진다. +DRAINAGE_ARROW_SPACING_M = float(os.getenv("DRAINAGE_ARROW_SPACING_M", "40.0")) +# 블록 안에서 화살표를 낼 수 있는 셀이 이 비율 미만이면 건너뛴다(가장자리 조각 방지). +DRAINAGE_ARROW_MIN_COVERAGE = float(os.getenv("DRAINAGE_ARROW_MIN_COVERAGE", "0.5")) +# 방향 일치도 하한(원형 평균 결과 길이 0~1). 블록 안 방향이 제각각이면 평균이 무의미하므로 버린다. +DRAINAGE_ARROW_MIN_AGREEMENT = float(os.getenv("DRAINAGE_ARROW_MIN_AGREEMENT", "0.7")) +# 단계별 검증 산출물은 같은 폴더에 `{번호}_{단계}.geojson` + `manifest.json`으로 쌓인다. +# 파일명 규칙은 B05_Profile_Engine_Watershed_Export.STAGES가 유일한 정의처다. + +# ── 확률강우량 (map.wamis.go.kr 등우선도 — 제주 전용) ── +# 등우선 서버 원본은 제주도만 커버한다(2026-08-13 실측) — 제주 공사지에서만 쓴다. +# 근거·검증: docs/raw/guidelines/2026-08-05_임도_배수설계_규정_조사.md 5절, PLAN.md A. +WAMIS_CONTOUR_CACHE_DIR = PROJECT_ROOT / "resources" / "data_rainfall_idf_cache" +# 프로젝트별 결과 파일명. drainage/ 아래에 놓인다. +DRAINAGE_RAINFALL_FILENAME = "rainfall_table.json" + +# ── 확률강우량 (관측소 방식 — 본토, 2026-08-13 사용자 확정 로직) ── +# 최근접 관측소 + 이 반경 안 관측소를 모두 받아 100년/24hr 최대 지점을 선정한다. +WAMIS_STATION_RADIUS_M = float(os.getenv("WAMIS_STATION_RADIUS_M", "10000")) +# 관측소 목록·확률강우량 xlsx의 프로젝트별 보존 폴더명. drainage/ 아래에 놓인다 +# (원천이 갱신되는 자료라 프로젝트 시점 스냅샷 — 전역 공통 캐시 아님). +DRAINAGE_RAINFALL_STATION_DIRNAME = "rainfall_stations" +# 설계빈도 강우강도 산출 방식: "mononobe"(기본, 실무 수리계산서 방식) | "general" +DRAINAGE_RAINFALL_IDF_METHOD = os.getenv("DRAINAGE_RAINFALL_IDF_METHOD", "mononobe") + +# ── 배수 유효직경 계산 계수 (2026-08-05 사용자 확정, 위 규정 조사 문서 6절) ── +# 합리식 유출계수 C. 산악지 준용 0.8 — 필요 시 사용자가 이 값을 고친다. +DRAINAGE_RUNOFF_COEFFICIENT = float(os.getenv("DRAINAGE_RUNOFF_COEFFICIENT", "0.8")) +# 설계유량 배수. 시행규칙 별표2 (가): 최대홍수유출량의 2.0배 이상. +DRAINAGE_DESIGN_FLOW_FACTOR = float(os.getenv("DRAINAGE_DESIGN_FLOW_FACTOR", "2.0")) +# 설계빈도(년). 별표2 (가) 1호: 100년빈도 확률강우량. +DRAINAGE_DESIGN_RETURN_PERIOD_YR = int(os.getenv("DRAINAGE_DESIGN_RETURN_PERIOD_YR", "100")) +# 홍수도달시간 하한(분). 국도건설공사 설계실무요령·도로배수지침 "강우지속기간 5분 원칙". +DRAINAGE_TC_MIN_MINUTES = float(os.getenv("DRAINAGE_TC_MIN_MINUTES", "5.0")) +# 관 경사(도). 시공 중 수시 변경되므로 지형 산출 대신 실무 대푯값 10도로 고정(사용자 제시). +DRAINAGE_PIPE_SLOPE_DEG = float(os.getenv("DRAINAGE_PIPE_SLOPE_DEG", "10.0")) +# Manning 조도계수(파형강관). +DRAINAGE_MANNING_N = float(os.getenv("DRAINAGE_MANNING_N", "0.024")) +# 관내 유속 허용범위(m/s). 도로배수지침 0.8~3.0 — 상한 클램프가 안전측(관이 커진다). +DRAINAGE_VELOCITY_MIN_MS = float(os.getenv("DRAINAGE_VELOCITY_MIN_MS", "0.8")) +DRAINAGE_VELOCITY_MAX_MS = float(os.getenv("DRAINAGE_VELOCITY_MAX_MS", "3.0")) +# 원형관 통수단면 비율. 도로설계요령: 관 단면의 70%만 통수 고려. +DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7")) +# 세월교 검토 문턱(mm). 유효직경이 관 최대 규격(파형강관 D2000)을 넘으면 관이 아니라 +# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단 +# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사). +DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000")) +# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서 +# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로 +# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만). +DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500")) +# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**. +# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다. +DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500) + +# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ── +# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자 +# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구 +# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240° +# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다 +# (original/실무문서/_숨김탭분석.md §6-1·§6-2). +FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017")) +FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10")) + +# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ── +# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다. +STRUCTURE_TYPES = ("배관", "기성막이", "대피로", "기타") +STRUCTURE_DEFAULT_TYPE = "배관" +PIPE_DIAMETERS_MM = { + "이중벽관": (150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), + "삼중벽관": (200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500), + "파형강관": ( + 150, + 200, + 250, + 300, + 350, + 400, + 450, + 500, + 600, + 700, + 800, + 900, + 1000, + 1200, + 1350, + 1500, + 1650, + 1800, + 2000, + ), +} +PIPE_DEFAULT_TYPE = "파형강관" +# 자동 지정 기본 관경. 별표2 (나) 예외 하한 800mm(사용자 결정 — 계산값이 더 크면 바로 위 규격). +PIPE_DEFAULT_DIAMETER_MM = int(os.getenv("PIPE_DEFAULT_DIAMETER_MM", "800")) +ESCAPE_ROUTE_WIDTHS_M = (1.5, 2.0, 2.5, 3.0) +ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0 +STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물" + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4. 종횡단 생성 파라미터 (B06 WF3) +# ───────────────────────────────────────────────────────────────────────── +SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0")) +# 초기 파이프라인(파일입력→B06) 샘플 반폭 — 표시 반폭보다 넉넉히 뽑아 두면 사용자가 +# 표시 반폭을 이 안에서 바꿀 때 재계산이 필요 없다(2026-08-06 사용자 확정, 기준 20m). +# 표시 반폭이 이 값을 넘을 때만 B05부터 재생성한다. 향후 사용자 평균 설정 보고 조정. +SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "20.0")) +SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) +SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) +SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) +SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" +FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-1. 표준 횡단면 단일 진실 원천 (도면 판독값, 2026-07-24 사용자 확정) +# +# 출처: `08 표준횡단도면(울진 울진 대흥 산65 외2(3공구)).bmp` 좌측 영역 판독값. +# 지반그룹 3종: soil(토사) / rock(암, 리핑·발파 공유) / paved(포장). +# 단위: 길이 m, 경사비 수평:수직=ratio:1, 횡단경사 %. +# +# 이 상수가 **표준 횡단면 기하의 유일한 정의처**다. B06 설정 패널 기본값과 +# 절·성토 단면적 엔진(B06_Section_Engine_Design)이 모두 여기를 읽으며, +# 아래 5-4-2의 파생 상수 외에 같은 값을 별도로 정의하지 않는다. +# ───────────────────────────────────────────────────────────────────────── +STANDARD_CROSS_SECTION = { + "soil": { + "road_width_m": 3.0, # 노폭(차도) + "shoulder_left_m": 0.5, # 노견(좌) + "shoulder_right_m": 0.5, # 노견(우) + # 측구(상단폭/저폭/깊이). 토사 = 900/300/300mm. + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 3.0, "max": 5.0}, # 횡단경사(측구방향) + "fill_slope_ratio": 1.2, # 성토 1:1.2 + "cut_slope_ratio": 1.0, # 절토 1:1.0 + }, + "rock": { # 리핑암·발파암 공유 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + # 일반 측구 = 690/300/300mm. + "ditch": {"top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, + # L형 측구 = 폭500 × 깊이100mm (횡단면도에서 일반/L형 중 선택). + "ditch_l_type": {"width_m": 0.5, "depth_m": 0.1}, + "cross_slope_pct": {"min": 3.0, "max": 3.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 0.4, # 절토(암) 1:0.4 (규정 1:0.3 이상) + }, + "paved": { # 포장: 토사와 동일 기하 + 횡단경사만 다름 + "road_width_m": 3.0, + "shoulder_left_m": 0.5, + "shoulder_right_m": 0.5, + "ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3}, + "cross_slope_pct": {"min": 1.5, "max": 2.0}, + "fill_slope_ratio": 1.2, + "cut_slope_ratio": 1.0, + # 포장층 두께(도면 미표기 — 임도 콘크리트 포장 실무 표준 0.2m, 패널에서 편집 가능). + "pavement_thickness_m": 0.2, + }, +} +# 암 경계선(지면선 복사 — 계획선 아님) 기본 오프셋과 상/하 제어 스텝(m). +STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M = -0.5 +STANDARD_ROCK_BOUNDARY_STEP_M = 0.1 + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-2. 횡단 표준단면 엔진 파생 상수 (B06 WF3) +# +# 전부 위 5-4-1 STANDARD_CROSS_SECTION에서 파생한다(중복 정의 금지). +# 지반유형 라벨은 3종(토사/리핑암/발파암)이나, 표준단면 기하는 토사/암반 2종 +# 프리셋만 존재한다(리핑암·발파암은 절토경사·측구 동일, 단가만 B08/B09에서 구분). +# ───────────────────────────────────────────────────────────────────────── +_SCS_SOIL = STANDARD_CROSS_SECTION["soil"] +# 노면폭(노견 포함) = 0.5 + 3.0 + 0.5 = 4.0m. env로만 개별 재정의 가능. +SECTION_ROADBED_WIDTH_M = float( + os.getenv( + "SECTION_ROADBED_WIDTH_M", + str( + _SCS_SOIL["road_width_m"] + _SCS_SOIL["shoulder_left_m"] + _SCS_SOIL["shoulder_right_m"] + ), + ) +) +SECTION_CARRIAGEWAY_WIDTH_M = float( + os.getenv("SECTION_CARRIAGEWAY_WIDTH_M", str(_SCS_SOIL["road_width_m"])) +) +# 성토 경사(수평:수직 = ratio:1). 지반유형 무관 고정. +SECTION_FILL_SLOPE_RATIO = float( + os.getenv("SECTION_FILL_SLOPE_RATIO", str(_SCS_SOIL["fill_slope_ratio"])) +) +# 지반유형(저장 라벨) → 기하 프리셋 키. 견적 단가 구분은 라벨 자체로 유지한다. +SECTION_GROUND_TYPE_PRESET = { + "soil": "soil", + "ripping_rock": "rock", + "blasting_rock": "rock", +} +# 단면유형: 좌절/우절(편절편성), 양절, 양성. 좌=양(+)offset, 우=음(-)offset. +SECTION_MODES = ("left_cut", "right_cut", "both_cut", "both_fill") +SECTION_DITCH_SIDES = ("left", "right") +# 측구 형식: 일반(사다리꼴) / L형(암 구간 전용 선택지). +SECTION_DITCH_TYPES = ("standard", "l_type") + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-3. 토량환산계수 (B06 유토곡선) +# +# loose(L) = 흐트러진 상태 토량 / 자연상태 토량 (팽창률) +# compacted(C) = 다져진 상태 토량 / 자연상태 토량 (다짐률) +# +# 유토곡선은 운반계획 도면이므로 **다짐상태 기준**으로 계산한다. +# "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, +# 내역서에 적용하는 수량은 자연상태로 한다." +# (2021년도 국도건설공사 설계실무 요령 / 2016 건설공사 표준품셈 계열) +# +# 아래 값은 표준품셈 토량환산계수표의 암종별 범위를 B06 지반유형 3종에 대응시킨 +# 제안값이며 임도 전용 고시 수치가 아니다. +# 토사 ← 풍화토(L 1.10~1.25 / C 0.80~0.90) ~ 점토(L 1.20~1.35 / C 0.75~0.90) +# 리핑암 ← 풍화암(L 1.30~1.35 / C 1.00~1.15) ~ 연암(L 1.30~1.50 / C 1.00~1.30) 하단 +# 발파암 ← 보통암(L 1.55~1.70 / C 1.20~1.40) 중앙 +# 표준품셈도 "토질 시험하여 적용하는 것을 원칙으로 하되 소량인 경우 환산계수표에 +# 따를 수 있다"고 하므로 현장별 조정이 전제다. 값을 바꾸려면 여기만 고치면 된다. +# +# 이 상수가 **토량환산계수의 유일한 정의처**다(타 파일 중복 정의 금지). +# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 8절 +# ───────────────────────────────────────────────────────────────────────── +EARTHWORK_CONVERSION_FACTORS = { + "soil": {"loose": 1.25, "compacted": 0.90}, + "ripping_rock": {"loose": 1.35, "compacted": 1.15}, + "blasting_rock": {"loose": 1.60, "compacted": 1.30}, +} + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-5. 토공 운반장비 선정 거리 경계 (B06 유토곡선 운반계획) +# +# 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` +# **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. +# +# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 +# +# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 +# **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, +# 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. +# 현장·발주처 기준에 따라 달라질 수 있으므로 +# **이 상수가 거리 경계의 유일한 정의처**이며 여기만 고치면 된다. +# +# key는 도면 표기와 다음과 같이 대응한다: free_haul=종무대, dozer=도쟈, dump_truck=덤프. +# 화면 표기 문구는 하드코딩 금지 규칙에 따라 ui_locales에서 따로 관리한다. +# 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md +# ───────────────────────────────────────────────────────────────────────── +EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( + ("free_haul", 20.0), + ("dozer", 70.0), + ("dump_truck", None), +) + + +# ───────────────────────────────────────────────────────────────────────── +# 5-4-2. 자연방토 판정 경사 (B06 유토곡선) +# +# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리. +# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면 +# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다. +# +# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와 +# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다. +# ───────────────────────────────────────────────────────────────────────── +NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 + + +# ───────────────────────────────────────────────────────────────────────── +# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2) +# +# 출처: 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 임도의 설계 및 시설기준. +# 기준은 임도등급이 아니라 `설계속도 × 지형구분(일반/특수)`으로 규정되어 있어 +# 등급은 grade_to_design_speed로 간접 매핑한다. +# 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 +# 서로 혼용하지 않는다. +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_PROFILE_CRITERIA = { + # 설계속도(km/h)별 법정 기준 + "design_speed": { + 40: { + "max_grade_pct": {"normal": 7.0, "special": 10.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 450.0, + "min_curve_length_m": 40.0, + }, + 30: { + "max_grade_pct": {"normal": 8.0, "special": 12.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 250.0, + "min_curve_length_m": 30.0, + }, + 20: { + "max_grade_pct": {"normal": 9.0, "special": 14.0}, + "max_reverse_grade_pct": 5.0, + "min_vertical_radius_m": 100.0, + "min_curve_length_m": 20.0, + }, + }, + # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 + # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 + # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 + # 설계속도가 오면 그 값이 우선한다(design_speed_kph). + # trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도 + # branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다. + "grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20}, + # 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3). + "selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "work": (20,)}, + # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 + "paved_exception_grade_pct": 18.0, + # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 + "vertical_curve_skip_delta_pct": 5.0, + # 곡선 중첩 방지용 최소 직선(tangent) 길이 (규정 외 실무 기본값) + "min_tangent_length_m": 20.0, + # 절·성토 균형 구역 기본 길이 (None이면 노선 전체를 1구역으로 본다) + "balance_segment_length_m": None, + # 주 진행방향 자동 판정 기준: |시종점 고도차| / (최고−최저) 가 이 값 이상이면 + # 한 방향으로 오르내리는 노선으로 보고 반대 방향에 역기울기 상한을 적용한다. + # V자(계곡 횡단)·Λ자(능선 통과) 노선은 이 비율이 낮아 역기울기 판정을 하지 않는다. + "main_direction_monotone_ratio": 0.5, +} +GRADE_TERRAIN_TYPES = ("normal", "special") +# 주 진행방향: auto(자동 판정) / ascending(상행) / descending(하행) / none(역기울기 미적용) +GRADE_MAIN_DIRECTIONS = ("auto", "ascending", "descending", "none") + + +# ───────────────────────────────────────────────────────────────────────── +# 5-6. 종단 계획선 선형(직선 + 측점 위 종단곡선) 및 편집 정책 (B05 WF2) +# +# 위 5-5의 FOREST_ROAD_PROFILE_CRITERIA는 법정 기준값이고, 여기는 계획선을 +# "지반 추종 직선 분할 + 측점 위 종단곡선"으로 만들고 사용자가 0.1m 단위로 +# 편집하는 동작을 제어하는 실무 정책값이다. 서로 혼용하지 않는다. +# +# 변화점(PVI)은 반드시 기준 측점 위에만 놓이며, 종단곡선은 그 측점을 중심으로 +# 대칭 배치되어 곡선 좌우에 직선 구간이 반드시 남는다. +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_PROFILE_ALIGNMENT = { + # 절·성토 균형 허용 오차: |절토 − 성토| / max(절토, 성토) (%) + # 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면 + # 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다. + "balance_tolerance_percent": 10.0, + # 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치). + # R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다). + # L = R × |기울기 대수차 A(비율)| + # 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로 + # 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다 + # (예: 20.0 → R=400m, A=10%일 때 L=40m). + "curve_radius_ratio": 0.40, + # **기본 종단곡선 길이 L(m) — 모든 변화점의 1차 기준값.** + # 임도 계획선은 변화점 사이 대수차 A가 작아(1~3%가 흔하다) 반경 R을 기준으로 잡으면 + # L = R × A 가 변화점마다 3~50m로 널뛰고, 작은 쪽은 도면에서 직선과 구분되지 않는다 + # (2026-08-03 사용자 지적). 그래서 **L을 기준값으로 고정**하고 R = L / A 로 역산한다. + # 결과적으로 호 길이가 변화점마다 같아 도면이 고르게 읽힌다. + # 인접 직선이 짧아 `curve_tangent_max_ratio`에 걸리면 넣을 수 있는 **최대 L**까지만 + # 줄이고 경고를 남긴다(0으로 죽이지 않는다 — 2026-08-08 사용자 지시). + # 사용자가 그 변화점 R을 직접 지정하면 그쪽이 이긴다. 호를 더/덜 길게 보이려면 여기만 고친다. + "default_curve_length_m": 15.0, + # 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율. + # 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다. + "curve_tangent_max_ratio": 0.45, + # 법정 예외(비포장 & 대수차 5% 이하)를 실제 기하에서도 곡선 생략으로 적용할지. + # 규정 다-(3)-(다)는 "두지 않을 수 있다"는 허용 조항이며, 임도 실무 도면은 대수차가 + # 작아도 변화점을 원곡선으로 처리한다. 기본값 False = 곡선을 항상 삽입하고 + # 해당 구간에는 "생략 가능" 표시만 남긴다(True로 바꾸면 곡선을 실제로 뺀다). + "curve_skip_legal_exception": False, + # 변화점(PVI) 추가 페널티 (직선 분할 DP 목적함수, 단위 m²). + # 변화점 하나를 늘리려면 잔차제곱합이 이 값 이상 개선되어야 채택된다. + "pvi_penalty_m2": 25.0, + # 직선 분할 시 한 구간이 가질 수 있는 최소 측점 개수 (짧은 토막 방지) + "min_segment_stations": 2, + # 사용자 편집 스텝(m). 그래프 상·하단 버튼 1클릭당 계획고 이동량. + "edit_step_m": 0.1, + # 법정 종단기울기 상한 초과 시 동작: "warn"(경고만) | "block"(편집 차단) + "grade_violation_policy": "warn", +} diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py new file mode 100644 index 00000000..a44511b1 --- /dev/null +++ b/config/config_system_terrain.py @@ -0,0 +1,186 @@ +"""지형 분석·지표면 모델 생성 파라미터 (5장·5-2장). + +`config_system.py` 가 700줄을 넘어 떼어냈다(2026-09-04). 값·이름은 그대로이고, +`config_system` 이 다시 내보내므로 기존 `from config.config_system import …` 는 불변이다. +""" + +import os + +# 5. 지형 분석 알고리즘 파라미터 +# ───────────────────────────────────────────────────────────────────────── +# Trimesh 메쉬 생성 +MESH_GRID_SIZE = float(os.getenv("MESH_GRID_SIZE", "1.0")) # 미터 단위 +MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) +SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) +SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) +SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) +SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) + +# CSF (Cloth Simulation Filter) 지면 분류 파라미터 +SURFACE_CSF_CLOTH_RESOLUTION_M = float(os.getenv("SURFACE_CSF_CLOTH_RESOLUTION_M", "1.5")) +SURFACE_CSF_RIGIDNESS = int(os.getenv("SURFACE_CSF_RIGIDNESS", "1")) +SURFACE_CSF_TIME_STEP = float(os.getenv("SURFACE_CSF_TIME_STEP", "0.65")) +SURFACE_CSF_CLASS_THRESHOLD_M = float(os.getenv("SURFACE_CSF_CLASS_THRESHOLD_M", "0.5")) +# 낙하 후 스프링 정착에 쓰는 여유 반복 수 (실제 낙하 구간은 현장 기복에서 계산한다) +SURFACE_CSF_ITERATIONS = int(os.getenv("SURFACE_CSF_ITERATIONS", "150")) +# 낙하+정착 반복 상한 — 기복 1500m(0.3185m/회)까지 커버한다 +SURFACE_CSF_MAX_ITERATIONS = int(os.getenv("SURFACE_CSF_MAX_ITERATIONS", "5000")) +SURFACE_CSF_SLOPE_SMOOTH = os.getenv("SURFACE_CSF_SLOPE_SMOOTH", "True").lower() == "true" +SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M = float( + os.getenv("SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M", "1.8") +) + +# PMF (Progressive Morphological Filter) 지면 분류 파라미터 +SURFACE_PMF_CELL_SIZE_M = float(os.getenv("SURFACE_PMF_CELL_SIZE_M", "2.0")) +SURFACE_PMF_MAX_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_MAX_WINDOW_SIZE", "40")) +SURFACE_PMF_INITIAL_WINDOW_SIZE = int(os.getenv("SURFACE_PMF_INITIAL_WINDOW_SIZE", "3")) +SURFACE_PMF_SLOPE = float(os.getenv("SURFACE_PMF_SLOPE", "1.0")) +SURFACE_PMF_MAX_DISTANCE_M = float(os.getenv("SURFACE_PMF_MAX_DISTANCE_M", "2.5")) + +# RANSAC (Local plane fitting) 지면 분류 파라미터 +SURFACE_RANSAC_DISTANCE_THRESHOLD_M = float(os.getenv("SURFACE_RANSAC_DISTANCE_THRESHOLD_M", "0.3")) +SURFACE_RANSAC_N = int(os.getenv("SURFACE_RANSAC_N", "3")) +SURFACE_RANSAC_ITERATIONS = int(os.getenv("SURFACE_RANSAC_ITERATIONS", "100")) +SURFACE_RANSAC_LOCAL_GRID_SIZE_M = float(os.getenv("SURFACE_RANSAC_LOCAL_GRID_SIZE_M", "10.0")) +SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42")) + +# LAS 자체 지면분류(class 2) 필터 — 이 비율 이상 분류돼 있을 때만 필터로 제공한다. +# 미분류 LAS는 classification이 전부 0이라 마스크가 비어 버린다. +SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float( + os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002") +) +# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다. +SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01")) + +# 계획노선이 지표면 밖으로 나가 잘릴 때, 잘린 쪽 끝에서 더 깎을 길이(m). +# 서피스 가장자리는 점 밀도가 떨어져 외곽선이 불규칙하다 — 경계에 딱 붙여 자르면 +# 그 구간 지반고가 못 미덥다 (2026-09-01 사용자 확정). +SURFACE_ROUTE_EDGE_TRIM_M = float(os.getenv("SURFACE_ROUTE_EDGE_TRIM_M", "30.0")) + +# ───────────────────────────────────────────────────────────────────────── +# 5-2. 지표면 모델 생성 파라미터 (TIN/DTM/NURBS/implicit/meshfree) +# ───────────────────────────────────────────────────────────────────────── +# 고를 수 있는 지면 필터 전체 — 관리자가 B04 드롭다운에서 요청할 때 쓰인다. +# 자동 전처리가 미리 만드는 조합이 아니다(그건 SURFACE_AUTO_* 가 정한다). +SURFACE_MODEL_SOURCE_FILTERS = tuple( + os.getenv("SURFACE_MODEL_SOURCE_FILTERS", "classification,grid_min_z,csf,pmf").split(",") +) +# 자동 전처리가 만드는 조합 — 기본 필터 1종 × 아래 표현. 나머지는 관리자 요청 시 만든다. +SURFACE_AUTO_METHODS = tuple(os.getenv("SURFACE_AUTO_METHODS", "dtm").split(",")) +# 기본 필터는 입력 LAS를 보고 정한다 — 지면분류(class 2)가 있으면 그것을, 없으면 아래 값. +SURFACE_AUTO_FALLBACK_FILTER = os.getenv("SURFACE_AUTO_FALLBACK_FILTER", "csf") +# dtm을 먼저 빌드해야 TIN 등고선 사전 캐시가 dtm footprint를 참조할 수 있다 (PLAN D-6) +SURFACE_MODEL_PRECOMPUTE = tuple( + os.getenv("SURFACE_MODEL_PRECOMPUTE", "dtm,tin,nurbs,implicit,meshfree").split(",") +) +SURFACE_MODEL_SMOOTHING_METHODS = tuple( + os.getenv("SURFACE_MODEL_SMOOTHING_METHODS", "dtm,tin").split(",") +) +SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_SECONDS", "0")) + +# footprint(외곽) 산출 +SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0")) +SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0")) +SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0")) +SURFACE_KEEP_LARGEST_FOOTPRINT = ( + os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true" +) +SURFACE_TILE_SIZE_M = float(os.getenv("SURFACE_TILE_SIZE_M", "50.0")) +SURFACE_MAX_PREVIEW_VERTICES = int(os.getenv("SURFACE_MAX_PREVIEW_VERTICES", "500000")) + +# 표현별 파라미터 (old 버전 검증값 기준 — PLAN F) +SURFACE_TIN_MAX_INPUT_POINTS = int(os.getenv("SURFACE_TIN_MAX_INPUT_POINTS", "500000")) +SURFACE_DTM_GRID_RESOLUTION_M = float(os.getenv("SURFACE_DTM_GRID_RESOLUTION_M", "1.0")) +SURFACE_NURBS_DEGREE = int(os.getenv("SURFACE_NURBS_DEGREE", "3")) +SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0")) +SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int( + os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16") +) +SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int( + os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000") +) +SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1")) +SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000")) +SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15")) + +# 스무딩 파라미터 +SURFACE_SMOOTHING_DTM_SIGMA_M = float(os.getenv("SURFACE_SMOOTHING_DTM_SIGMA_M", "0.5")) +SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH = float(os.getenv("SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH", "0.0")) +SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M = float( + os.getenv("SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M", "0.5") +) +SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS = int( + os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS", "10") +) +SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA", "0.5")) +SURFACE_SMOOTHING_TIN_TAUBIN_MU = float(os.getenv("SURFACE_SMOOTHING_TIN_TAUBIN_MU", "-0.53")) + +# 등고선 파라미터 +SURFACE_CONTOUR_INTERVAL_M = float(os.getenv("SURFACE_CONTOUR_INTERVAL_M", "1.0")) +SURFACE_CONTOUR_GRID_RESOLUTION_M = float(os.getenv("SURFACE_CONTOUR_GRID_RESOLUTION_M", "1.0")) + +# 도엽등고선 3D 서피스 (LAS 없는 설계 — 2026-08-30 사용자 확정) +# 노선 XY bbox에 더하는 절취 여유(m). 300m = 횡단·코리도·성토면 여유(사용자 확정값). +SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0")) +# 도엽등고선 DTM 격자 한 변(m). LAS DTM·등고선 캐시와 같은 1m(사용자 확정값). +SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0")) +# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다 +# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py. +SHEET_SURFACE_METHODS = [ + method.strip() + for method in os.getenv( + "SHEET_SURFACE_METHODS", + "tin_sheet,tin,biharmonic,anudem,multires,laplace", + ).split(",") + if method.strip() +] +# 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). +SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") +# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 +# WF1의 대부분(용화 실측 891초 중 655초)을 여기서 쓴다. 나머지는 관리자가 B04에서 +# 그 방식을 고를 때 만든다 (2026-09-01 사용자 확정). +SHEET_SURFACE_AUTO_METHODS = [ + method.strip() + for method in os.getenv("SHEET_SURFACE_AUTO_METHODS", SHEET_SURFACE_DEFAULT_METHOD).split(",") + if method.strip() +] + +# 일반 사용자 WF1 자동 확정 기본값 +SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf") +SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm") +SURFACE_CONFIRM_DEFAULT_SMOOTH = ( + os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true" +) + + +def build_surface_model_config() -> dict: + """지표면 모델 파이프라인이 사용하는 config dict를 조립한다.""" + return { + "source_filters": list(SURFACE_MODEL_SOURCE_FILTERS), + "precompute": list(SURFACE_MODEL_PRECOMPUTE), + "smoothing_methods": tuple(SURFACE_MODEL_SMOOTHING_METHODS), + "sync_timeout_seconds": SURFACE_MODEL_SYNC_TIMEOUT_SECONDS, + "footprint_resolution_meters": SURFACE_FOOTPRINT_RESOLUTION_M, + "footprint_gap_close_meters": SURFACE_FOOTPRINT_GAP_CLOSE_M, + "boundary_inset_meters": SURFACE_BOUNDARY_INSET_M, + "keep_largest_footprint": SURFACE_KEEP_LARGEST_FOOTPRINT, + "tile_size_meters": SURFACE_TILE_SIZE_M, + "max_preview_vertices": SURFACE_MAX_PREVIEW_VERTICES, + "tin_max_input_points": SURFACE_TIN_MAX_INPUT_POINTS, + "dtm_grid_resolution_meters": SURFACE_DTM_GRID_RESOLUTION_M, + "nurbs_degree": SURFACE_NURBS_DEGREE, + "nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M, + "nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS, + "implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE, + "implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING, + "meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS, + "meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M, + "smoothing_dtm_sigma_meters": SURFACE_SMOOTHING_DTM_SIGMA_M, + "smoothing_dtm_spline_smooth": SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH, + "smoothing_dtm_preview_resolution_meters": SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M, + "smoothing_tin_taubin_iterations": SURFACE_SMOOTHING_TIN_TAUBIN_ITERATIONS, + "smoothing_tin_taubin_lambda": SURFACE_SMOOTHING_TIN_TAUBIN_LAMBDA, + "smoothing_tin_taubin_mu": SURFACE_SMOOTHING_TIN_TAUBIN_MU, + "contour_interval_meters": SURFACE_CONTOUR_INTERVAL_M, + "contour_grid_resolution_meters": SURFACE_CONTOUR_GRID_RESOLUTION_M, + } -- 2.54.0 From 0b16a1240c13607a23e526edd98134577a234728 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:49:06 +0900 Subject: [PATCH 075/441] =?UTF-8?q?fix(B07):=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=20=EC=9C=A0=EC=97=AD=EB=8F=84=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9E=AC=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_Support_Basin.py` 로 옮긴 뒤 `CONTOUR_FILE` · `STREAM_FILE` · `_basins_crs` · `_geojson_payload` · `_geojson_features` · `_too_far_from_route` 가 진입 파일에서 사라져, 옛 이름을 참조하던 다른 워크트리의 테스트가 ImportError·AttributeError 로 깨졌음(laptop-sub 보고 2건). 프로덕션 호출부는 새 모듈을 직접 봐 동작에는 영향 없었음. `as` 형태로 다시 내보내 분리 전 이름 14개가 진입 파일에서 모두 보이게 함. 검증: 진입 파일 속성 14개 전부 확인, 라우트 7개 유지, ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_Router_Support.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index bc045ce5..f3b1972f 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -39,6 +39,24 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields # 유역도 배경·파일 입출력 조각은 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 ( + 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 ( + _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 ( + _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, ) -- 2.54.0 From 322a2f1f13451cdd50021f8448566d8de92c07d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:51:39 +0900 Subject: [PATCH 076/441] =?UTF-8?q?fix(B04):=20=EB=B6=84=EB=A6=AC=EB=A1=9C?= =?UTF-8?q?=20=EC=82=AC=EB=9D=BC=EC=A7=84=20=EC=9D=B4=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EB=9D=BC=EC=9A=B0=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=AC=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 700줄 분리 때 _Router_Progress · _Router_Status 로 옮겨 진입 파일에서 안 보이게 된 PROGRESS_FILE_RELATIVE · _progress_file_path · read_surface_progress · get_wf1_analysis_status · get_surface_model_preview 를 as 재노출로 되살림. 옛 경로를 보던 호출부·테스트가 그대로 동작하게 하려는 것(laptop-main 의 B07 사례와 같은 사고 예방). 검증: 분리 전 모듈 정의 이름을 전수 대조해 누락 0개(_Router 16개 · _Router_Watershed 30개), 라우트 9개 유지, ruff check/format 통과, tmp/tests 183 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_Router.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index c4e6ad6a..6de4818d 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -29,8 +29,22 @@ from B04_PreProcess.B04_PreProcess_Repository import ( list_surface_models, save_surface_analysis_to_db, ) + +# 700줄 분리로 옮긴 이름을 **진입 파일에서 그대로 다시 노출**한다 — 옛 경로를 보던 +# 호출부·테스트가 그대로 동작하게 하려는 것(2026-09-04, laptop-main 사례로 확인). from B04_PreProcess.B04_PreProcess_Router_Progress import ( - write_surface_progress, + PROGRESS_FILE_RELATIVE as PROGRESS_FILE_RELATIVE, +) +from B04_PreProcess.B04_PreProcess_Router_Progress import _progress_file_path as _progress_file_path +from B04_PreProcess.B04_PreProcess_Router_Progress import ( + read_surface_progress as read_surface_progress, +) +from B04_PreProcess.B04_PreProcess_Router_Progress import write_surface_progress +from B04_PreProcess.B04_PreProcess_Router_Status import ( + get_surface_model_preview as get_surface_model_preview, +) +from B04_PreProcess.B04_PreProcess_Router_Status import ( + get_wf1_analysis_status as get_wf1_analysis_status, ) from B04_PreProcess.B04_PreProcess_Router_Status import router as status_router from B04_PreProcess.B04_PreProcess_Schema import ( -- 2.54.0 From fbbedc72300803dd51425bc602cfa2f32c8b4be2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 10:53:37 +0900 Subject: [PATCH 077/441] =?UTF-8?q?fix(700=EC=A4=84=EB=B6=84=EB=A6=AC):=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=A0=84=20=EC=9D=B4=EB=A6=84=20=EC=A0=84?= =?UTF-8?q?=EB=B6=80=20=EC=9E=AC=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit laptop-sub 가 쓴 AST 대조법(분리 전 커밋의 모듈 정의 이름 ↔ 지금 모듈 `dir()`)을 내 4개에도 적용해 누락 26개를 찾아 되살림. - `B03_FileInput_Router.py` 누락 17 → 0 (헬퍼 13 + 청크 엔드포인트 4) - `B03_FileInput_Router_Temp.py` 누락 5 → 0 (`_total_chunks` + 청크 엔드포인트 4) - `B07_DesignDetail_Router_Support.py` 누락 3 → 0 (`_BASIN_MAX_DISTANCE_M` · `_clip_segment` · `_geometry_lines`) - `config/config_system.py` 누락 1 → 0 (`_SCS_SOIL` — `import *` 가 밑줄 이름을 안 실음) 검증: 네 파일 모두 **정의 이름 누락 0**(28·19·36·246개 대조), 라우트 수 유지 (B03 7 · temp 9+attach 1 · B07 7), ruff check 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_Router.py | 72 +++++++++++++++++-- B03_FileInput/B03_FileInput_Router_Temp.py | 31 ++++++-- .../B07_DesignDetail_Router_Support.py | 9 +++ config/config_system.py | 1 + 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 2867abd5..e573962f 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -23,14 +23,76 @@ from B03_FileInput.B03_FileInput_Repository import ( list_incomplete_upload_sessions, list_project_input_files, ) + +# 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다 — 옛 이름을 참조하는 +# 테스트·스크립트가 깨지지 않게 하기 위함이다(2026-09-04). +from B03_FileInput.B03_FileInput_Router_Chunks import ( + create_project_upload_session as create_project_upload_session, +) +from B03_FileInput.B03_FileInput_Router_Chunks import ( + finalize_project_upload as finalize_project_upload, +) +from B03_FileInput.B03_FileInput_Router_Chunks import ( + get_project_upload_status as get_project_upload_status, +) from B03_FileInput.B03_FileInput_Router_Chunks import router as chunk_router +from B03_FileInput.B03_FileInput_Router_Chunks import ( + upload_project_chunk as upload_project_chunk, +) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response from B03_FileInput.B03_FileInput_Router_Helpers import ( - _REQUIRED_FILE_TYPES, - _complete_file_input_if_ready, - _missing_required_file_types, - _schedule_background_task, - _write_stage_metadata, + _ANALYSIS_RUNNING_MESSAGE as _ANALYSIS_RUNNING_MESSAGE, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _BACKGROUND_TASKS as _BACKGROUND_TASKS, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _POINT_CLOUD_FILE_TYPES as _POINT_CLOUD_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _REQUIRED_FILE_TYPES as _REQUIRED_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ROUTE_FILE_TYPES as _ROUTE_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _already_uploaded as _already_uploaded, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _complete_file_input_if_ready as _complete_file_input_if_ready, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _get_project_notification_info as _get_project_notification_info, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _is_point_cloud_result as _is_point_cloud_result, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _missing_required_file_types as _missing_required_file_types, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _require_complete_file_set as _require_complete_file_set, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _schedule_background_task as _schedule_background_task, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _send_upload_complete_notification as _send_upload_complete_notification, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _stored_fingerprint as _stored_fingerprint, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _total_chunks as _total_chunks, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _update_project_status as _update_project_status, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _write_stage_metadata as _write_stage_metadata, ) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, diff --git a/B03_FileInput/B03_FileInput_Router_Temp.py b/B03_FileInput/B03_FileInput_Router_Temp.py index 3304a0fe..e962669b 100644 --- a/B03_FileInput/B03_FileInput_Router_Temp.py +++ b/B03_FileInput/B03_FileInput_Router_Temp.py @@ -40,12 +40,35 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( upsert_temp_batch_file, ) from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response + +# 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다(2026-09-04). +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + create_batch_upload_session as create_batch_upload_session, +) +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + finalize_batch_upload as finalize_batch_upload, +) +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + get_batch_upload_status as get_batch_upload_status, +) from B03_FileInput.B03_FileInput_Router_Temp_Chunks import router as chunk_router +from B03_FileInput.B03_FileInput_Router_Temp_Chunks import ( + upload_batch_chunk as upload_batch_chunk, +) from B03_FileInput.B03_FileInput_Router_Temp_Support import ( - _POINT_CLOUD_FILE_TYPES, - _batch_root, - _iso, - _refresh_batch_status, + _POINT_CLOUD_FILE_TYPES as _POINT_CLOUD_FILE_TYPES, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _batch_root as _batch_root, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _iso as _iso, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _refresh_batch_status as _refresh_batch_status, +) +from B03_FileInput.B03_FileInput_Router_Temp_Support import ( + _total_chunks as _total_chunks, ) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index f3b1972f..0381742e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -36,6 +36,9 @@ 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 경로는 불변이다. @@ -48,12 +51,18 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( 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, ) diff --git a/config/config_system.py b/config/config_system.py index 6757cffd..50a826ca 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -111,6 +111,7 @@ AUTO_DESIGN_CHAIN_ENABLED = os.getenv("AUTO_DESIGN_CHAIN_ENABLED", "True").lower # 5장(지형·설계 파라미터)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). # 여기서 그대로 다시 내보내므로 `from config.config_system import …` 호출부는 불변이다. from config.config_system_design import * # noqa: E402,F401,F403 +from config.config_system_design import _SCS_SOIL as _SCS_SOIL # noqa: E402 from config.config_system_terrain import * # noqa: E402,F401,F403 # ───────────────────────────────────────────────────────────────────────── -- 2.54.0 From 9c141ac717220ba578524eedca855877f56b4131 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:12:05 +0900 Subject: [PATCH 078/441] =?UTF-8?q?refactor(B05):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EC=BD=9C=EB=B0=B1=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20(726=20->=20688=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RouteProfilePanelCallbacks 인터페이스만 B05_Profile_UI_Profile_Panel_Types.ts(52줄)로 그대로 옮기고, 진입 파일에서 다시 export 해 옛 임포트 경로(_Profile_Render 등)를 유지. 런타임 코드는 한 줄도 건드리지 않음. 검증: 분리 전 export 2개(RouteProfilePanelCallbacks · createRouteProfilePanel)가 진입 파일에서 그대로 보임, tsc --noEmit 통과, prettier 유지. 공용 브라우저(5174) 실조작 — 종단 패널 측점 66개·표 12행·줌 버튼 7개·유역 11개 정상, 측점 선택 후 방향키 862.99->863.09m, Ctrl+Z 로 862.99m 복귀. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 46 ++-------------- .../B05_Profile_UI_Profile_Panel_Types.ts | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 42 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 08880c4b..23da8778 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -14,7 +14,6 @@ import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { createProfileTableOverlay, @@ -95,47 +94,10 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60; * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. */ -/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ -export interface RouteProfilePanelCallbacks { - /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. - * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ - onPipesChanged?: ( - pipes: Array<{ - chainage_m: number; - effective_diameter_mm: number | null; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ - design_flow_m3s?: number | null; - facility: PipeFacility; - start_m?: number; - end_m?: number; - source?: PipeSource; - options?: Record; - }>, - ) => void; - /** 테이블에서 구조물 라인을 끌어 옮김. */ - onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; - /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ - onStructureRemove?: (station: IrregularStation) => void; - /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ - onPipeAdd?: (chainageM: number) => void; - /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ - onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; - /** 구조물 라인을 눌러 고름. */ - onIrregularSelect?: (station: IrregularStation) => void; - /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ - onStructureSelect?: (structureId: string | null) => void; - /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ - onStructureMarkMove?: (structureId: string, toChainageM: number) => void; - /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ - onStructureTypeAdd?: (chainageM: number, typeId: string) => void; - /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ - onBasinSelected?: (chainageM: number | null) => void; - /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ - onPipeSelected?: (chainageM: number | null) => void; - /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — - * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ - onCrossDesignsUpdated?: () => void; -} +/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로 + * 동작하도록 여기서 다시 내보낸다(2026-09-04). */ +import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; +export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types"; export function createRouteProfilePanel( projectId: string, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts new file mode 100644 index 00000000..78c08118 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -0,0 +1,52 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Panel_Types.ts + * 종단 패널이 Page 로 올려 보내는 알림(콜백) 타입. + * + * `B05_Profile_UI_Profile_Panel` 이 700줄을 넘겨 **타입만** 떼어낸 조각이다(2026-09-04). + * 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다. + * ========================================================================== */ + +import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; + +/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ +export interface RouteProfilePanelCallbacks { + /** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다. + * 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */ + onPipesChanged?: ( + pipes: Array<{ + chainage_m: number; + effective_diameter_mm: number | null; + /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ + design_flow_m3s?: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + source?: PipeSource; + options?: Record; + }>, + ) => void; + /** 테이블에서 구조물 라인을 끌어 옮김. */ + onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; + /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ + onStructureRemove?: (station: IrregularStation) => void; + /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ + onPipeAdd?: (chainageM: number) => void; + /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ + onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; + /** 구조물 라인을 눌러 고름. */ + onIrregularSelect?: (station: IrregularStation) => void; + /** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */ + onStructureSelect?: (structureId: string | null) => void; + /** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */ + onStructureMarkMove?: (structureId: string, toChainageM: number) => void; + /** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */ + onStructureTypeAdd?: (chainageM: number, typeId: string) => void; + /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ + onBasinSelected?: (chainageM: number | null) => void; + /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ + onPipeSelected?: (chainageM: number | null) => void; + /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — + * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ + onCrossDesignsUpdated?: () => void; +} -- 2.54.0 From 37fc63ba4cb93210a7cc17b146f32bb8754c7079 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:18:22 +0900 Subject: [PATCH 079/441] =?UTF-8?q?refactor(B04):=20=EC=A7=80=ED=91=9C?= =?UTF-8?q?=EB=A9=B4=203D=20=EB=B7=B0=EC=96=B4=20700=EC=A4=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B04_PreProcess_UI_TerrainViewer.ts 958 -> 698줄. - _TerrainViewer_Chrome.ts(209줄) — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척·컴파스· 표고 범례·로딩 서클 DOM 생성. 만드는 순서·클래스·인라인 스타일 그대로. - _TerrainViewer_Contours.ts(189줄) — 등고선 적재(fetch·라인 생성·라벨·범례). 뷰어 클로저가 쥐던 값만 ctx 로 받고 본문 수치·로직은 그대로. 호출부는 얇은 래퍼로 유지. 검증: export 2개(SurfaceTerrainViewer · createSurfaceTerrainViewer) 분리 전과 동일, tsc --noEmit 통과, prettier 적용. 공용 브라우저(5174) B04 실조작 — 뷰어 2개·캔버스 2개 렌더, 상태줄 CLASSIFICATION · DTM · 등고선 1m, 등고 라벨 40개(상한), 범례 940m/760m, 등고선 토글을 끄면 라벨 0 → 켜면 40 복귀, 간격 5m 적용 시 상태가 5m 로 바뀌고 1m 로 되돌리면 원상 복귀. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_UI_TerrainViewer.ts | 364 +++--------------- .../B04_PreProcess_UI_TerrainViewer_Chrome.ts | 209 ++++++++++ ...04_PreProcess_UI_TerrainViewer_Contours.ts | 189 +++++++++ 3 files changed, 450 insertions(+), 312 deletions(-) create mode 100644 B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts create mode 100644 B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts index 86e879a3..ad92027c 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer.ts @@ -3,11 +3,11 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createProgressCircle } from "@ui/ui_template_progress"; // 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다. -import { createTerrainCompass } from "@ui/ui_template_compass"; +import { buildTerrainViewerChrome } from "./B04_PreProcess_UI_TerrainViewer_Chrome"; +import { loadContourLinesInto } from "./B04_PreProcess_UI_TerrainViewer_Contours"; import { routeLineColor } from "./B04_PreProcess_UI_MapRender"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch"; import { @@ -21,10 +21,6 @@ import { type SurfaceCameraState, } from "./B04_PreProcess_UI_Camera"; -/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다. 조각이 많은 지형에서 - * 라벨이 수백 개가 되면 매 프레임 위치 재계산이 화면을 멈춰 세운다(2026-08-30). */ -const MAX_CONTOUR_LABELS = 40; - function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -56,166 +52,32 @@ export interface SurfaceTerrainViewer { } export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { - const root = document.createElement("div"); - root.className = "terrain-model-group"; - - const statusSpan = document.createElement("span"); - statusSpan.className = "terrain-status"; - statusSpan.style.fontSize = "var(--text-caption)"; - statusSpan.style.color = "var(--color-text-secondary)"; - statusSpan.textContent = "모델 선택 대기 중..."; + // DOM 뼈대는 700줄 제한으로 `_TerrainViewer_Chrome` 로 옮겼다 — 이름·순서는 그대로다. + const { + root, + statusSpan, + axesCheck, + surfCheck, + smoothLabel, + smoothSelect, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + minValSpan, + progress, + } = buildTerrainViewerChrome(); let activeFilter = "csf"; let activeMethod = "dtm"; - const axesCheck = document.createElement("input"); - axesCheck.type = "checkbox"; - axesCheck.checked = false; - - const rightControls = document.createElement("div"); - rightControls.className = "viewer-options model-display-options"; - - // Surface Toggle - const surfLabel = document.createElement("label"); - surfLabel.className = "toggle-label toggle-button"; - const surfCheck = document.createElement("input"); - surfCheck.type = "checkbox"; - surfCheck.checked = true; - surfLabel.append(surfCheck, document.createTextNode(" 서피스")); - - // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. - // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). - // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b04-surface__field"; - const smoothCaption = document.createElement("span"); - smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); - const smoothSelect = document.createElement("select"); - smoothSelect.className = "b04-surface__select"; - ( - [ - ["on", "B04_Surface_Smoothing_On"], - ["off", "B04_Surface_Smoothing_Off"], - ] as const - ).forEach(([value, key]) => { - const option = document.createElement("option"); - option.value = value; - option.textContent = L(key); - smoothSelect.append(option); - }); - smoothSelect.value = "on"; - smoothLabel.append(smoothCaption, smoothSelect); - - // Contour Toggle - const contourLabel = document.createElement("label"); - contourLabel.className = "toggle-label toggle-button"; - const contourCheck = document.createElement("input"); - contourCheck.type = "checkbox"; - contourCheck.checked = true; - contourLabel.append(contourCheck, document.createTextNode(" 등고선")); - - // Contour Interval input form - const intervalForm = document.createElement("form"); - intervalForm.className = "contour-interval-form"; - - const intervalInput = document.createElement("input"); - intervalInput.type = "number"; - intervalInput.value = "1.0"; - intervalInput.step = "0.5"; - intervalInput.min = "0.5"; - intervalInput.className = "contour-interval-input"; - - const intervalSubmit = document.createElement("button"); - intervalSubmit.type = "submit"; - intervalSubmit.textContent = "적용"; - intervalSubmit.className = "contour-interval-submit"; - - intervalForm.append( - document.createTextNode("간격 "), - intervalInput, - document.createTextNode("m "), - intervalSubmit, - ); - - const axesLabel = document.createElement("label"); - axesLabel.className = "toggle-label toggle-button"; - axesLabel.append(axesCheck, document.createTextNode(" 축")); - rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); - - // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. - const optionsContent = document.createElement("div"); - optionsContent.className = "terrain-options-content"; - optionsContent.append(rightControls, statusSpan); - - // 3D View container - const viewerArea = document.createElement("div"); - viewerArea.className = "three-viewer"; - viewerArea.style.position = "relative"; - viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; - viewerArea.style.overflow = "hidden"; - - const canvas = document.createElement("canvas"); - canvas.className = "b04-surface-viewer__canvas"; - viewerArea.append(canvas); - root.append(viewerArea); - - // Scale bar overlay - const scaleBar = document.createElement("div"); - scaleBar.className = "b04-surface__scale"; - scaleBar.hidden = true; - - const scaleLabel = document.createElement("span"); - scaleBar.append(scaleLabel); - viewerArea.append(scaleBar); - - // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. - const compass = createTerrainCompass({ className: "b04-surface__compass" }); - viewerArea.append(compass.root); - - // Elevation bounds legend bar overlay (I-403) - const legendBar = document.createElement("div"); - legendBar.style.position = "absolute"; - legendBar.style.top = "16px"; - legendBar.style.right = "16px"; - legendBar.style.background = "rgba(255, 255, 255, 0.9)"; - legendBar.style.border = "1px solid #cbd5e1"; - legendBar.style.borderRadius = "6px"; - legendBar.style.padding = "8px"; - legendBar.style.width = "50px"; - legendBar.style.display = "none"; // hidden until contours are loaded - legendBar.style.flexDirection = "column"; - legendBar.style.alignItems = "center"; - legendBar.style.zIndex = "10"; - legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; - legendBar.style.pointerEvents = "none"; - - const maxValSpan = document.createElement("span"); - maxValSpan.style.fontSize = "10px"; - maxValSpan.style.fontWeight = "bold"; - maxValSpan.style.color = "#b91c1c"; - maxValSpan.style.marginBottom = "4px"; - - const gradientDiv = document.createElement("div"); - gradientDiv.style.width = "12px"; - gradientDiv.style.height = "120px"; - gradientDiv.style.background = - "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; - gradientDiv.style.borderRadius = "2px"; - gradientDiv.style.border = "1px solid #94a3b8"; - - const minValSpan = document.createElement("span"); - minValSpan.style.fontSize = "10px"; - minValSpan.style.fontWeight = "bold"; - minValSpan.style.color = "#1d4ed8"; - minValSpan.style.marginTop = "4px"; - - legendBar.append(maxValSpan, gradientDiv, minValSpan); - viewerArea.append(legendBar); - - // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. - const progress = createProgressCircle({ overlay: true }); - progress.root.hidden = true; - viewerArea.append(progress.root); - /** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */ function showProgress(ratio: number | null, label: string | null): void { progress.root.hidden = label === null; @@ -600,157 +462,35 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { } } - async function loadContourLines( + /** 등고선 적재는 700줄 제한으로 `_TerrainViewer_Contours` 로 옮겼다 — 호출부는 그대로다. */ + const loadContourLines = ( modelId: number, isSmooth: boolean, recalculate = false, - ): Promise { - const interval = parseFloat(intervalInput.value) || 1.0; - const projectId = currentProjectId; - const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; - - try { - // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. - const data = await fetchCachedJson(projectId, contourUrl); - if ( - currentProjectId !== projectId || - currentModelId !== modelId || - currentModelSmooth !== isSmooth || - (parseFloat(intervalInput.value) || 1.0) !== interval - ) { - return false; - } - - clearContours(); - - const bounds = data.bounds; - if (!bounds) return false; - - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; - - const transform = (coords: [number, number, number][]) => { - return coords.map(([x_model, y_model, z_model]) => { - const x_scene = x_model - cx; - const y_scene = z_model - cz; - const z_scene = -(y_model - cy); - return new THREE.Vector3(x_scene, y_scene, z_scene); - }); - }; - - let minH = Infinity; - let maxH = -Infinity; - // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. - // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). - const majorPoints: THREE.Vector3[] = []; - const minorPoints: THREE.Vector3[] = []; - const labelCandidates: { - level: number; - position: THREE.Vector3; - length: number; - }[] = []; - - data.contours.forEach((c: any) => { - if (c.level < minH) minH = c.level; - if (c.level > maxH) maxH = c.level; - - const points = transform(c.coordinates); - if (points.length < 2) return; - - const isMajor = c.level % (interval * 5) === 0; - const bucket = isMajor ? majorPoints : minorPoints; - for (let i = 0; i < points.length - 1; i++) { - bucket.push(points[i], points[i + 1]); - } - - // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 - // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 - // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. - if (isMajor && points.length > 4) { - let length = 0; - for (let i = 0; i < points.length - 1; i++) { - length += points[i].distanceTo(points[i + 1]); - } - labelCandidates.push({ - level: c.level, - position: points[Math.floor(points.length / 2)], - length, - }); - } - }); - - labelCandidates.sort((a, b) => b.length - a.length); - for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { - const labelPos = candidate.position; - const labelDiv = document.createElement("div"); - labelDiv.className = "contour-label"; - labelDiv.innerText = `${Math.round(candidate.level)}m`; - labelDiv.style.position = "absolute"; - labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; - labelDiv.style.border = "1px solid #d97706"; - labelDiv.style.color = "#b45309"; - labelDiv.style.padding = "1px 4px"; - labelDiv.style.borderRadius = "3px"; - labelDiv.style.fontSize = "9px"; - labelDiv.style.fontWeight = "bold"; - labelDiv.style.pointerEvents = "none"; - labelDiv.style.zIndex = "5"; - labelDiv.style.transform = "translate(-50%, -50%)"; - - (labelDiv as any).__updateLabelPos = () => { - if (!contourCheck.checked) { - labelDiv.style.display = "none"; - return; - } - const proj = labelPos.clone().project(camera); - const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth; - const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight; - - if (proj.z > 1) { - labelDiv.style.display = "none"; - } else { - labelDiv.style.display = "block"; - labelDiv.style.left = `${x}px`; - labelDiv.style.top = `${y}px`; - } - }; - - viewerArea.appendChild(labelDiv); - labelElements.push(labelDiv); - labelsDirty = true; - } - - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. - [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { - if (points.length === 0) return; - const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); - contourGroup.add(new THREE.LineSegments(geometry, material)); - }); - - if (minH !== Infinity && maxH !== -Infinity) { - const nearestMin10 = Math.round(minH / 10) * 10; - const nearestMax10 = Math.round(maxH / 10) * 10; - maxValSpan.textContent = `${nearestMax10}m`; - minValSpan.textContent = `${nearestMin10}m`; - legendBar.style.display = "flex"; - } else { - legendBar.style.display = "none"; - } - return true; - } catch (e) { - legendBar.style.display = "none"; - return false; - } - } + ): Promise => + loadContourLinesInto( + { + viewerArea, + legendBar, + maxValSpan, + minValSpan, + intervalInput, + camera, + contourGroup, + contourCheck, + projectId: () => currentProjectId, + currentModelId: () => currentModelId, + currentModelSmooth: () => currentModelSmooth, + labelElements, + setLabelsDirty: (dirty) => { + labelsDirty = dirty; + }, + clearContours, + }, + modelId, + isSmooth, + recalculate, + ); async function loadSelectedContours( modelId: number, diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts new file mode 100644 index 00000000..9df8648a --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Chrome.ts @@ -0,0 +1,209 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Chrome.ts + * 지표면 3D 뷰어의 **DOM 뼈대** — 상태줄·표시 토글·등고선 간격 폼·뷰포트·축척 막대· + * 방위 컴파스·표고 범례·로딩 서클을 만들어 넘긴다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 만드는 순서·클래스·인라인 스타일은 옮기기 전 그대로다. 이벤트 배선과 THREE 렌더링은 + * 뷰어 본체에 남아 있고, 여기서는 엘리먼트만 만든다. + * ========================================================================== */ + +import { createTerrainCompass } from "@ui/ui_template_compass"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createProgressCircle } from "@ui/ui_template_progress"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 뷰어 본체가 이어 쓸 엘리먼트 묶음 — 이름은 분리 전 지역변수와 같다. */ +export type TerrainViewerChrome = ReturnType; + +export function buildTerrainViewerChrome() { + const root = document.createElement("div"); + root.className = "terrain-model-group"; + + const statusSpan = document.createElement("span"); + statusSpan.className = "terrain-status"; + statusSpan.style.fontSize = "var(--text-caption)"; + statusSpan.style.color = "var(--color-text-secondary)"; + statusSpan.textContent = "모델 선택 대기 중..."; + + const axesCheck = document.createElement("input"); + axesCheck.type = "checkbox"; + axesCheck.checked = false; + + const rightControls = document.createElement("div"); + rightControls.className = "viewer-options model-display-options"; + + // Surface Toggle + const surfLabel = document.createElement("label"); + surfLabel.className = "toggle-label toggle-button"; + const surfCheck = document.createElement("input"); + surfCheck.type = "checkbox"; + surfCheck.checked = true; + surfLabel.append(surfCheck, document.createTextNode(" 서피스")); + + // 스무딩(tin/dtm 전용) — 지면 필터·서피스와 함께 "지표면 분석" 컨테이너로 옮겼다. + // 주변 입력과 양식을 맞추려고 버튼이 아니라 드롭다운이다(2026-08-01 사용자 지시). + // 상태·재렌더 배선은 여기 그대로 두고, 페이지는 이 엘리먼트를 원하는 자리에 놓기만 한다. + const smoothLabel = document.createElement("label"); + smoothLabel.className = "b04-surface__field"; + const smoothCaption = document.createElement("span"); + smoothCaption.textContent = L("B04_Surface_Field_Smoothing"); + const smoothSelect = document.createElement("select"); + smoothSelect.className = "b04-surface__select"; + ( + [ + ["on", "B04_Surface_Smoothing_On"], + ["off", "B04_Surface_Smoothing_Off"], + ] as const + ).forEach(([value, key]) => { + const option = document.createElement("option"); + option.value = value; + option.textContent = L(key); + smoothSelect.append(option); + }); + smoothSelect.value = "on"; + smoothLabel.append(smoothCaption, smoothSelect); + + // Contour Toggle + const contourLabel = document.createElement("label"); + contourLabel.className = "toggle-label toggle-button"; + const contourCheck = document.createElement("input"); + contourCheck.type = "checkbox"; + contourCheck.checked = true; + contourLabel.append(contourCheck, document.createTextNode(" 등고선")); + + // Contour Interval input form + const intervalForm = document.createElement("form"); + intervalForm.className = "contour-interval-form"; + + const intervalInput = document.createElement("input"); + intervalInput.type = "number"; + intervalInput.value = "1.0"; + intervalInput.step = "0.5"; + intervalInput.min = "0.5"; + intervalInput.className = "contour-interval-input"; + + const intervalSubmit = document.createElement("button"); + intervalSubmit.type = "submit"; + intervalSubmit.textContent = "적용"; + intervalSubmit.className = "contour-interval-submit"; + + intervalForm.append( + document.createTextNode("간격 "), + intervalInput, + document.createTextNode("m "), + intervalSubmit, + ); + + const axesLabel = document.createElement("label"); + axesLabel.className = "toggle-label toggle-button"; + axesLabel.append(axesCheck, document.createTextNode(" 축")); + rightControls.append(axesLabel, surfLabel, contourLabel, intervalForm); + + // 표시 토글 묶음 + 상태 줄. 컨테이너(제목)는 페이지가 만든다 — 포인트 옵션과 한 칸을 쓴다. + const optionsContent = document.createElement("div"); + optionsContent.className = "terrain-options-content"; + optionsContent.append(rightControls, statusSpan); + + // 3D View container + const viewerArea = document.createElement("div"); + viewerArea.className = "three-viewer"; + viewerArea.style.position = "relative"; + viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)"; + viewerArea.style.overflow = "hidden"; + + const canvas = document.createElement("canvas"); + canvas.className = "b04-surface-viewer__canvas"; + viewerArea.append(canvas); + root.append(viewerArea); + + // Scale bar overlay + const scaleBar = document.createElement("div"); + scaleBar.className = "b04-surface__scale"; + scaleBar.hidden = true; + + const scaleLabel = document.createElement("span"); + scaleBar.append(scaleLabel); + viewerArea.append(scaleBar); + + // 방위 콤파스 — 축척 막대 반대편(우하단). 바늘 각도는 애니메이션 루프가 맞춘다. + const compass = createTerrainCompass({ className: "b04-surface__compass" }); + viewerArea.append(compass.root); + + // Elevation bounds legend bar overlay (I-403) + const legendBar = document.createElement("div"); + legendBar.style.position = "absolute"; + legendBar.style.top = "16px"; + legendBar.style.right = "16px"; + legendBar.style.background = "rgba(255, 255, 255, 0.9)"; + legendBar.style.border = "1px solid #cbd5e1"; + legendBar.style.borderRadius = "6px"; + legendBar.style.padding = "8px"; + legendBar.style.width = "50px"; + legendBar.style.display = "none"; // hidden until contours are loaded + legendBar.style.flexDirection = "column"; + legendBar.style.alignItems = "center"; + legendBar.style.zIndex = "10"; + legendBar.style.boxShadow = "0 2px 6px rgba(0,0,0,0.08)"; + legendBar.style.pointerEvents = "none"; + + const maxValSpan = document.createElement("span"); + maxValSpan.style.fontSize = "10px"; + maxValSpan.style.fontWeight = "bold"; + maxValSpan.style.color = "#b91c1c"; + maxValSpan.style.marginBottom = "4px"; + + const gradientDiv = document.createElement("div"); + gradientDiv.style.width = "12px"; + gradientDiv.style.height = "120px"; + gradientDiv.style.background = + "linear-gradient(to bottom, #d60000 0%, #ff5100 25%, #e6a100 50%, #228b22 75%, #3a85ff 100%)"; + gradientDiv.style.borderRadius = "2px"; + gradientDiv.style.border = "1px solid #94a3b8"; + + const minValSpan = document.createElement("span"); + minValSpan.style.fontSize = "10px"; + minValSpan.style.fontWeight = "bold"; + minValSpan.style.color = "#1d4ed8"; + minValSpan.style.marginTop = "4px"; + + legendBar.append(maxValSpan, gradientDiv, minValSpan); + viewerArea.append(legendBar); + + // 뷰포트 정중앙 로딩 서클 — 메쉬 파일은 수십 MB라 내려받는 동안 화면이 비어 보인다. + const progress = createProgressCircle({ overlay: true }); + progress.root.hidden = true; + viewerArea.append(progress.root); + + return { + root, + statusSpan, + axesCheck, + rightControls, + surfLabel, + surfCheck, + smoothLabel, + smoothCaption, + smoothSelect, + contourLabel, + contourCheck, + intervalForm, + intervalInput, + intervalSubmit, + axesLabel, + optionsContent, + viewerArea, + canvas, + scaleBar, + scaleLabel, + compass, + legendBar, + maxValSpan, + gradientDiv, + minValSpan, + progress, + }; +} diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts new file mode 100644 index 00000000..a9e59ff2 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * B04_PreProcess_UI_TerrainViewer_Contours.ts + * 지표면 3D 뷰어의 **등고선 적재** — 서버(또는 보관함)에서 등고선을 받아 THREE 라인으로 + * 얹고, 표고 범례(최고·최저)를 갱신한다. + * + * `B04_PreProcess_UI_TerrainViewer` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-04). + * 본문 로직·수치는 그대로이고, 뷰어 클로저가 쥐고 있던 값만 `ctx` 로 받는다. + * ========================================================================== */ + +import * as THREE from "three"; + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedJson } from "../A00_Common/b_asset_cache"; + +/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ +const MAX_CONTOUR_LABELS = 40; + +/** 뷰어 본체가 쥔 값 중 등고선 적재에 필요한 것들. */ +export interface ContourLoadContext { + viewerArea: HTMLElement; + legendBar: HTMLElement; + maxValSpan: HTMLElement; + minValSpan: HTMLElement; + intervalInput: HTMLInputElement; + camera: THREE.PerspectiveCamera; + contourGroup: THREE.Group; + contourCheck: HTMLInputElement; + /** 지금 보고 있는 프로젝트·모델 — 응답이 늦게 와도 최신 요청만 반영하려고 함수로 받는다. */ + projectId: () => string; + currentModelId: () => number | null; + currentModelSmooth: () => boolean; + /** 등고 라벨 DOM 목록(뷰어와 **같은 배열**을 공유한다)과 다시 배치하라는 표시. */ + labelElements: HTMLDivElement[]; + setLabelsDirty: (dirty: boolean) => void; + clearContours: () => void; +} + +export async function loadContourLinesInto( + ctx: ContourLoadContext, + modelId: number, + isSmooth: boolean, + recalculate = false, +): Promise { + const interval = parseFloat(ctx.intervalInput.value) || 1.0; + const projectId = ctx.projectId(); + const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`; + + try { + // 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다. + const data = await fetchCachedJson(projectId, contourUrl); + if ( + ctx.projectId() !== projectId || + ctx.currentModelId() !== modelId || + ctx.currentModelSmooth() !== isSmooth || + (parseFloat(ctx.intervalInput.value) || 1.0) !== interval + ) { + return false; + } + + ctx.clearContours(); + + const bounds = data.bounds; + if (!bounds) return false; + + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + + const transform = (coords: [number, number, number][]) => { + return coords.map(([x_model, y_model, z_model]) => { + const x_scene = x_model - cx; + const y_scene = z_model - cz; + const z_scene = -(y_model - cy); + return new THREE.Vector3(x_scene, y_scene, z_scene); + }); + }; + + let minH = Infinity; + let maxH = -Infinity; + // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 되어 그리기가 느려진다. + // 주곡선·보조곡선 두 덩어리로 합쳐 객체 2개만 만든다(2026-08-01). + const majorPoints: THREE.Vector3[] = []; + const minorPoints: THREE.Vector3[] = []; + const labelCandidates: { + level: number; + position: THREE.Vector3; + length: number; + }[] = []; + + data.contours.forEach((c: any) => { + if (c.level < minH) minH = c.level; + if (c.level > maxH) maxH = c.level; + + const points = transform(c.coordinates); + if (points.length < 2) return; + + const isMajor = c.level % (interval * 5) === 0; + const bucket = isMajor ? majorPoints : minorPoints; + for (let i = 0; i < points.length - 1; i++) { + bucket.push(points[i], points[i + 1]); + } + + // 라벨은 여기서 만들지 않고 후보만 모은다 — 등고선이 잘게 쪼개지면 조각마다 + // 라벨이 붙어 수백 개가 되고, 매 프레임 위치 재계산이 화면을 멈춰 세운다 + // (2026-08-30 사용자 보고). 아래에서 긴 것부터 상한만큼만 만든다. + if (isMajor && points.length > 4) { + let length = 0; + for (let i = 0; i < points.length - 1; i++) { + length += points[i].distanceTo(points[i + 1]); + } + labelCandidates.push({ + level: c.level, + position: points[Math.floor(points.length / 2)], + length, + }); + } + }); + + labelCandidates.sort((a, b) => b.length - a.length); + for (const candidate of labelCandidates.slice(0, MAX_CONTOUR_LABELS)) { + const labelPos = candidate.position; + const labelDiv = document.createElement("div"); + labelDiv.className = "contour-label"; + labelDiv.innerText = `${Math.round(candidate.level)}m`; + labelDiv.style.position = "absolute"; + labelDiv.style.background = "rgba(255, 255, 255, 0.85)"; + labelDiv.style.border = "1px solid #d97706"; + labelDiv.style.color = "#b45309"; + labelDiv.style.padding = "1px 4px"; + labelDiv.style.borderRadius = "3px"; + labelDiv.style.fontSize = "9px"; + labelDiv.style.fontWeight = "bold"; + labelDiv.style.pointerEvents = "none"; + labelDiv.style.zIndex = "5"; + labelDiv.style.transform = "translate(-50%, -50%)"; + + (labelDiv as any).__updateLabelPos = () => { + if (!ctx.contourCheck.checked) { + labelDiv.style.display = "none"; + return; + } + const proj = labelPos.clone().project(ctx.camera); + const x = (proj.x * 0.5 + 0.5) * ctx.viewerArea.clientWidth; + const y = (-(proj.y * 0.5) + 0.5) * ctx.viewerArea.clientHeight; + + if (proj.z > 1) { + labelDiv.style.display = "none"; + } else { + labelDiv.style.display = "block"; + labelDiv.style.left = `${x}px`; + labelDiv.style.top = `${y}px`; + } + }; + + ctx.viewerArea.appendChild(labelDiv); + ctx.labelElements.push(labelDiv); + ctx.setLabelsDirty(true); + } + + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + [ + { points: minorPoints, color: 0xf59e0b }, + { points: majorPoints, color: 0xd97706 }, + ].forEach(({ points, color }) => { + if (points.length === 0) return; + const geometry = new THREE.BufferGeometry().setFromPoints(points); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.8, + }); + ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); + }); + + if (minH !== Infinity && maxH !== -Infinity) { + const nearestMin10 = Math.round(minH / 10) * 10; + const nearestMax10 = Math.round(maxH / 10) * 10; + ctx.maxValSpan.textContent = `${nearestMax10}m`; + ctx.minValSpan.textContent = `${nearestMin10}m`; + ctx.legendBar.style.display = "flex"; + } else { + ctx.legendBar.style.display = "none"; + } + return true; + } catch (e) { + ctx.legendBar.style.display = "none"; + return false; + } +} -- 2.54.0 From 41cfab22e9991a9334c0631e0373f546fd16e41c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:19:04 +0900 Subject: [PATCH 080/441] =?UTF-8?q?refactor(ui=5Ftemplate):=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20700=EC=A4=84?= =?UTF-8?q?=20=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=B0=A8=ED=8A=B8=C2=B7=EA=B8=B0=EB=B3=B8=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 917줄 한 파일을 넷으로 나눔 (동작 불변, 순수 이동). - `ui_template_elements.ts` 363줄 — 버튼·입력·선택·카드·태그·토스트·확인창·셸 - `ui_template_elements_styles.ts` 369줄 — `injectBaseStyles()` 와 규칙 문자열 - `ui_template_elements_chart.ts` 185줄 — 공통 라인 차트 - `ui_template_elements_base.ts` 33줄 — 요소 생성 헬퍼 `el` (셋이 함께 써 순환 방지) 본체가 `export *` 로 다시 내보내 호출부의 import 경로는 불변. 검증: 분리 전 `export` 25개 전부 유지(+`el` 공개 1개 추가), `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- ui_template/ui_template_elements.ts | 570 +-------------------- ui_template/ui_template_elements_base.ts | 33 ++ ui_template/ui_template_elements_chart.ts | 185 +++++++ ui_template/ui_template_elements_styles.ts | 370 +++++++++++++ 4 files changed, 596 insertions(+), 562 deletions(-) create mode 100644 ui_template/ui_template_elements_base.ts create mode 100644 ui_template/ui_template_elements_chart.ts create mode 100644 ui_template/ui_template_elements_styles.ts diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index d50e9d3b..9e74c56b 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -9,35 +9,16 @@ * - 스타일 규칙은 injectBaseStyles()로 1회 주입 (design.md 컴포넌트 명세 기반). * ========================================================================== */ -/* ----------------------------------------------------------------------------- - * 0. 내부 유틸 - * -------------------------------------------------------------------------- */ +import { el } from "./ui_template_elements_base"; + +// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. +export * from "./ui_template_elements_chart"; +export * from "./ui_template_elements_styles"; +export { el } from "./ui_template_elements_base"; /** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ -function el( - tag: K, - options: { - className?: string; - text?: string; - attrs?: Record; - children?: (HTMLElement | string)[]; - } = {}, -): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (options.className) node.className = options.className; - if (options.text !== undefined) node.textContent = options.text; - if (options.attrs) { - for (const [k, v] of Object.entries(options.attrs)) { - node.setAttribute(k, v); - } - } - if (options.children) { - for (const child of options.children) { - node.append(child); - } - } - return node; -} + /* ----------------------------------------------------------------------------- * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav @@ -380,538 +361,3 @@ export function createWorkflowShell(opts: WorkflowShellOptions): WorkflowShellHa * 외부 라이브러리 없이 인라인 SVG. 색상은 CSS 클래스 + theme.css 변수 참조. * -------------------------------------------------------------------------- */ -export interface LineChartSeries { - /** 범례에 표시할 이름 (i18n 결과 문자열) */ - name: string; - /** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */ - values: (number | null)[]; - /** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */ - colorIndex?: 0 | 1 | 2 | 3; -} - -export interface LineChartOptions { - series: LineChartSeries[]; - /** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */ - xLabels?: string[]; - /** y축 최대값 (기본: 100 = 퍼센트) */ - yMax?: number; - /** y축 단위 접미사 (기본: "%") */ - yUnit?: string; - /** 접근성 설명 */ - ariaLabel?: string; - /** 커스텀 가상 가로폭 (기본: CHART_W = 640) */ - width?: number; - /** 커스텀 가상 세로폭 (기본: CHART_H = 200) */ - height?: number; -} - -const CHART_W = 640; -const CHART_H = 200; -const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 }; -const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시) - -/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */ -function splinePath(points: { x: number; y: number }[]): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M${points[0].x},${points[0].y}`; - let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`; - for (let i = 0; i < points.length - 1; i += 1) { - const p0 = points[i - 1] ?? points[i]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[i + 2] ?? p2; - // Catmull-Rom (tension 1/6) → cubic Bézier 제어점 - const c1x = p1.x + (p2.x - p0.x) / 6; - const c1y = p1.y + (p2.y - p0.y) / 6; - const c2x = p2.x - (p3.x - p1.x) / 6; - const c2y = p2.y - (p3.y - p1.y) / 6; - d += - ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` + - `${c2x.toFixed(1)},${c2y.toFixed(1)} ` + - `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; - } - return d; -} - -/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */ -export function createLineChart(opts: LineChartOptions): HTMLDivElement { - const w = opts.width ?? CHART_W; - const h = opts.height ?? CHART_H; - const yMax = opts.yMax ?? 100; - const yUnit = opts.yUnit ?? "%"; - const wrap = el("div", { className: "ui-chart" }); - - const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length)); - if (pointCount < 2) { - wrap.append(el("div", { className: "ui-chart__empty", text: "—" })); - wrap.setAttribute("data-empty", "true"); - return wrap; - } - - const plotW = w - CHART_PAD.left - CHART_PAD.right; - const plotH = h - CHART_PAD.top - CHART_PAD.bottom; - const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1); - const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax); - - const svgNs = "http://www.w3.org/2000/svg"; - const svg = document.createElementNS(svgNs, "svg"); - svg.setAttribute("class", "ui-chart__svg"); - svg.setAttribute("viewBox", `0 0 ${w} ${h}`); - svg.setAttribute("role", "img"); - svg.setAttribute("width", "100%"); - svg.setAttribute("height", "100%"); - svg.setAttribute("preserveAspectRatio", "none"); - if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel); - - // y축 그리드 + 라벨 (0, 25, 50, 75, 100%) - for (let g = 0; g <= 4; g += 1) { - const v = (yMax / 4) * g; - const y = yAt(v); - const line = document.createElementNS(svgNs, "line"); - line.setAttribute("class", "ui-chart__grid"); - line.setAttribute("x1", String(CHART_PAD.left)); - line.setAttribute("x2", String(w - CHART_PAD.right)); - line.setAttribute("y1", String(y)); - line.setAttribute("y2", String(y)); - svg.append(line); - const tick = document.createElementNS(svgNs, "text"); - tick.setAttribute("class", "ui-chart__tick"); - tick.setAttribute("x", String(CHART_PAD.left - 6)); - tick.setAttribute("y", String(y + 4)); - tick.setAttribute("text-anchor", "end"); - tick.textContent = `${Math.round(v)}${yUnit}`; - svg.append(tick); - } - - // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드 - if (opts.xLabels && opts.xLabels.length > 0) { - const labels = opts.xLabels; - const baseY = CHART_PAD.top + plotH; - for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) { - const label = labels[idx]; - const x = xAt(idx); - if (label !== undefined && label !== "") { - // 수직 점선 그리드 - const vline = document.createElementNS(svgNs, "line"); - vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical"); - vline.setAttribute("x1", String(x)); - vline.setAttribute("x2", String(x)); - vline.setAttribute("y1", String(CHART_PAD.top)); - vline.setAttribute("y2", String(baseY)); - svg.append(vline); - // x축 라벨 - const tick = document.createElementNS(svgNs, "text"); - tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); - tick.setAttribute("x", String(x)); - tick.setAttribute("y", String(baseY + 16)); - tick.setAttribute("text-anchor", "middle"); - tick.textContent = label; - svg.append(tick); - } - } - } - - // 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리) - for (const s of opts.series) { - let segment: { x: number; y: number }[] = []; - let d = ""; - const flush = () => { - if (segment.length > 0) d += `${splinePath(segment)} `; - segment = []; - }; - s.values.forEach((v, i) => { - if (v === null || v === undefined) { - flush(); - return; - } - segment.push({ x: xAt(i), y: yAt(v) }); - }); - flush(); - const path = document.createElementNS(svgNs, "path"); - path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`); - path.setAttribute("d", d.trim()); - svg.append(path); - } - - // 범례 (그래프 영역 우상단 오버레이) - const legend = el("div", { className: "ui-chart__legend" }); - opts.series.forEach((s) => { - legend.append( - el("span", { - className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`, - text: s.name, - }), - ); - }); - - const plot = el("div", { className: "ui-chart__plot" }); - plot.append(svg, legend); - wrap.append(plot); - return wrap; -} - -/* ============================================================================= - * 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles) - * theme.css 변수만 참조. 앱 진입 시 1회 호출. - * ========================================================================== */ - -const BASE_STYLE_ID = "ui-template-elements-style"; - -const BASE_CSS = ` -[hidden] { display: none !important; } - -/* --- Button --- */ -.ui-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--spacing-8); - font-family: var(--font-body); - font-size: var(--text-body-sm); - font-weight: var(--font-weight-medium); - line-height: 1; - border: 1px solid transparent; - border-radius: var(--radius-buttons); - padding: var(--spacing-8) var(--spacing-16); - cursor: pointer; - transition: background-color var(--transition-fast), - border-color var(--transition-fast), color var(--transition-fast); -} -.ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } -.ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } - -.ui-btn--filled { - background-color: var(--color-primary); - color: var(--color-primary-text); - box-shadow: var(--shadow-sm); -} -.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); } - -.ui-btn--ghost { - background-color: transparent; - border-color: var(--color-primary); - color: var(--color-primary); -} -.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); } - -.ui-btn--pill { - background-color: transparent; - color: var(--color-plum-velvet); - border-radius: var(--radius-pills); -} -.ui-btn--pill:hover:not(:disabled), -.ui-btn--pill.is-active { background-color: var(--color-mist-violet); } - -.ui-btn--danger { - background-color: var(--color-danger); - color: var(--color-canvas); -} - -/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ -.ui-btn--glass { - border-color: color-mix(in srgb, var(--color-border) 65%, transparent); - background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); - color: var(--color-text-body); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); -} -.ui-btn--glass:hover:not(:disabled), -.ui-btn--glass.is-active { - border-color: var(--color-primary); - background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); - color: var(--color-primary-text); -} - -/* --- Input Field --- */ -.ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } -.ui-field__label { - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} -.ui-input { - font-family: var(--font-body); - font-size: var(--text-body-sm); - color: var(--color-text-body); - background-color: var(--color-canvas); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - padding: 10px 14px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.ui-input::placeholder { color: var(--color-text-muted); } -.ui-input:focus { - outline: none; - border-color: var(--color-focus-ring); - box-shadow: 0 0 0 1px var(--color-focus-ring); -} -.ui-input--error { border-color: var(--color-danger); } -.ui-field__error { - font-size: var(--text-caption); - color: var(--color-danger); - min-height: 0; - visibility: hidden; -} -.ui-field__error.is-visible { visibility: visible; } - -.ui-select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - font-family: var(--font-body); - font-size: var(--text-body-sm); - color: var(--color-text-body); - background-color: var(--color-canvas); - border: 1px solid var(--color-border); - border-radius: var(--radius-inputs); - padding: 10px 36px 10px 14px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px; - cursor: pointer; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.ui-select:focus { - outline: none; - border-color: var(--color-focus-ring); - box-shadow: 0 0 0 1px var(--color-focus-ring); -} -.ui-select:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* --- Card --- */ -.ui-card { - background-color: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - padding: var(--card-padding); - box-shadow: var(--shadow-sm); -} -.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; } -.ui-card__title { - font-family: var(--font-display); - color: var(--color-plum-velvet); - margin-bottom: var(--spacing-16); -} -.ui-card__body { display: flex; flex-direction: column; gap: var(--spacing-16); } - -/* --- Tag / Pill --- */ -.ui-tag { - display: inline-flex; - align-items: center; - border-radius: var(--radius-pills); - padding: var(--spacing-4) var(--spacing-16); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - line-height: 1.3; -} -.ui-tag--accent { background-color: var(--color-mist-violet); color: var(--color-royal-amethyst); } -.ui-tag--neutral { background-color: var(--color-paper); color: var(--color-slate); } -.ui-tag--success { background-color: var(--color-mist-violet); color: var(--color-success); } -.ui-tag--warning { background-color: var(--color-paper); color: var(--color-warning); } -.ui-tag--danger { background-color: var(--color-paper); color: var(--color-danger); } - -/* --- Loading Overlay + Spinner --- */ -.ui-overlay { - position: fixed; - inset: 0; - display: none; - align-items: center; - justify-content: center; - background-color: rgba(38, 17, 74, 0.24); - z-index: var(--z-overlay); -} -.ui-overlay.is-active { display: flex; } -.ui-spinner { - width: 40px; - height: 40px; - border: 3px solid var(--color-mist-violet); - border-top-color: var(--color-royal-amethyst); - border-radius: var(--radius-pills); - animation: ui-spin 0.8s linear infinite; -} -@keyframes ui-spin { to { transform: rotate(360deg); } } - -/* --- Toast --- */ -.ui-toast-container { - position: fixed; - top: var(--spacing-24); - right: var(--spacing-24); - display: flex; - flex-direction: column; - gap: var(--spacing-8); - z-index: var(--z-toast); -} -.ui-toast { - padding: var(--spacing-16) var(--spacing-24); - border-radius: var(--radius-cards); - font-size: var(--text-body-sm); - color: var(--color-canvas); - box-shadow: var(--shadow-lg); - opacity: 0; - transform: translateX(16px); - transition: opacity var(--transition-base), transform var(--transition-base); -} -.ui-toast.is-visible { opacity: 1; transform: translateX(0); } -.ui-toast--success { background-color: var(--color-success); } -.ui-toast--error { background-color: var(--color-danger); } -.ui-toast--warning { background-color: var(--color-warning); } -.ui-toast--info { background-color: var(--color-royal-amethyst); } - -/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */ -.ui-confirm { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(38, 17, 74, 0.35); - z-index: var(--z-overlay); -} -.ui-confirm__panel { - min-width: 300px; - max-width: 420px; - padding: var(--spacing-24); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} -.ui-confirm__message { - margin: 0 0 var(--spacing-16); - color: var(--color-text-body); - font-size: var(--text-body-sm); - white-space: pre-line; -} -.ui-confirm__actions { - display: flex; - justify-content: flex-end; - gap: var(--spacing-8); -} - -/* --- Workflow Shell (3단 레이아웃) --- */ -.ui-wf { display: flex; flex-direction: column; height: 100%; } -.ui-wf__header { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--wf-header-height); - padding: 0 var(--spacing-24); - border-bottom: 1px solid var(--color-border); - background-color: var(--color-surface-raised); -} -.ui-wf__title { font-size: var(--text-subheading); } -.ui-wf__body { display: flex; flex: 1; min-height: 0; } -.ui-wf__left { - width: var(--wf-left-panel-width); - flex-shrink: 0; - padding: var(--spacing-24); - border-right: 1px solid var(--color-border); - overflow-y: auto; - background-color: var(--color-surface); -} -.ui-wf__right { - flex: 1; - min-width: 0; - overflow: auto; - background-color: var(--color-bg); -} - -/* --- Line Chart --- */ -.ui-chart { - width: 100%; -} -.ui-chart__plot { - position: relative; - width: 100%; -} -.ui-chart__svg { - width: 100%; - height: auto; - display: block; -} -.ui-chart__empty { - display: flex; - align-items: center; - justify-content: center; - min-height: 120px; - color: var(--color-text-muted); - font-size: var(--text-body); -} -.ui-chart__grid { - stroke: var(--color-border); - stroke-width: 1; - opacity: 0.5; -} -.ui-chart__grid--vertical { - stroke-dasharray: 4, 4; -} -.ui-chart__tick { - fill: var(--color-text-muted); - font-size: 12px; - font-family: var(--font-body); -} -.ui-chart__tick--x { - font-size: 11px; -} -.ui-chart__line { - fill: none; - stroke-width: 2; - stroke-linejoin: round; - stroke-linecap: round; -} -.ui-chart__line--c0 { stroke: var(--color-chart-0); } -.ui-chart__line--c1 { stroke: var(--color-chart-1); } -.ui-chart__line--c2 { stroke: var(--color-chart-2); } -.ui-chart__line--c3 { stroke: var(--color-chart-3); } -.ui-chart__point { - stroke-width: 1.5; - stroke: var(--color-surface); -} -.ui-chart__point--c0 { fill: var(--color-chart-0); } -.ui-chart__point--c1 { fill: var(--color-chart-1); } -.ui-chart__point--c2 { fill: var(--color-chart-2); } -.ui-chart__point--c3 { fill: var(--color-chart-3); } - -.ui-chart__legend { - position: absolute; - top: var(--spacing-4); - right: var(--spacing-8); - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--spacing-8) var(--spacing-16); - padding: var(--spacing-4) var(--spacing-8); - border-radius: var(--radius-cards); - background-color: color-mix(in srgb, var(--color-surface) 78%, transparent); - pointer-events: none; -} -.ui-chart__legend-item { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-caption); - color: var(--color-text); -} -.ui-chart__legend-item::before { - content: ""; - width: 12px; - height: 3px; - border-radius: 2px; - background-color: currentColor; -} -.ui-chart__legend-item--c0 { color: var(--color-chart-0); } -.ui-chart__legend-item--c1 { color: var(--color-chart-1); } -.ui-chart__legend-item--c2 { color: var(--color-chart-2); } -.ui-chart__legend-item--c3 { color: var(--color-chart-3); } -`; - -/** 공통 컴포넌트 기본 스타일을 에 1회 주입. 앱 진입 시 호출. */ -export function injectBaseStyles(): void { - if (document.getElementById(BASE_STYLE_ID)) return; - const style = el("style", { attrs: { id: BASE_STYLE_ID } }); - style.textContent = BASE_CSS; - document.head.append(style); -} diff --git a/ui_template/ui_template_elements_base.ts b/ui_template/ui_template_elements_base.ts new file mode 100644 index 00000000..a5064e04 --- /dev/null +++ b/ui_template/ui_template_elements_base.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * ui_template_elements_base.ts + * 공통 컴포넌트의 내부 유틸 — 요소 생성 헬퍼. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 차트·스타일 + * 조각과 본체가 함께 쓰므로 순환 임포트를 피하려고 맨 아래층에 둔다. + * ========================================================================== */ + +/** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ +export function el( + tag: K, + options: { + className?: string; + text?: string; + attrs?: Record; + children?: (HTMLElement | string)[]; + } = {}, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = options.text; + if (options.attrs) { + for (const [k, v] of Object.entries(options.attrs)) { + node.setAttribute(k, v); + } + } + if (options.children) { + for (const child of options.children) { + node.append(child); + } + } + return node; +} diff --git a/ui_template/ui_template_elements_chart.ts b/ui_template/ui_template_elements_chart.ts new file mode 100644 index 00000000..0685caa4 --- /dev/null +++ b/ui_template/ui_template_elements_chart.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * ui_template_elements_chart.ts + * 공통 라인 차트 — 대시보드 리소스 그래프에 쓰는 SVG 꺾은선. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 그대로 + * 다시 내보내므로 호출부의 import 경로는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +export interface LineChartSeries { + /** 범례에 표시할 이름 (i18n 결과 문자열) */ + name: string; + /** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */ + values: (number | null)[]; + /** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */ + colorIndex?: 0 | 1 | 2 | 3; +} + +export interface LineChartOptions { + series: LineChartSeries[]; + /** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */ + xLabels?: string[]; + /** y축 최대값 (기본: 100 = 퍼센트) */ + yMax?: number; + /** y축 단위 접미사 (기본: "%") */ + yUnit?: string; + /** 접근성 설명 */ + ariaLabel?: string; + /** 커스텀 가상 가로폭 (기본: CHART_W = 640) */ + width?: number; + /** 커스텀 가상 세로폭 (기본: CHART_H = 200) */ + height?: number; +} + +const CHART_W = 640; +const CHART_H = 200; +const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 }; +const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시) + +/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */ +function splinePath(points: { x: number; y: number }[]): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M${points[0].x},${points[0].y}`; + let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`; + for (let i = 0; i < points.length - 1; i += 1) { + const p0 = points[i - 1] ?? points[i]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[i + 2] ?? p2; + // Catmull-Rom (tension 1/6) → cubic Bézier 제어점 + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = p1.y + (p2.y - p0.y) / 6; + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = p2.y - (p3.y - p1.y) / 6; + d += + ` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` + + `${c2x.toFixed(1)},${c2y.toFixed(1)} ` + + `${p2.x.toFixed(1)},${p2.y.toFixed(1)}`; + } + return d; +} + +/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */ +export function createLineChart(opts: LineChartOptions): HTMLDivElement { + const w = opts.width ?? CHART_W; + const h = opts.height ?? CHART_H; + const yMax = opts.yMax ?? 100; + const yUnit = opts.yUnit ?? "%"; + const wrap = el("div", { className: "ui-chart" }); + + const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length)); + if (pointCount < 2) { + wrap.append(el("div", { className: "ui-chart__empty", text: "—" })); + wrap.setAttribute("data-empty", "true"); + return wrap; + } + + const plotW = w - CHART_PAD.left - CHART_PAD.right; + const plotH = h - CHART_PAD.top - CHART_PAD.bottom; + const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1); + const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax); + + const svgNs = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(svgNs, "svg"); + svg.setAttribute("class", "ui-chart__svg"); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + svg.setAttribute("role", "img"); + svg.setAttribute("width", "100%"); + svg.setAttribute("height", "100%"); + svg.setAttribute("preserveAspectRatio", "none"); + if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel); + + // y축 그리드 + 라벨 (0, 25, 50, 75, 100%) + for (let g = 0; g <= 4; g += 1) { + const v = (yMax / 4) * g; + const y = yAt(v); + const line = document.createElementNS(svgNs, "line"); + line.setAttribute("class", "ui-chart__grid"); + line.setAttribute("x1", String(CHART_PAD.left)); + line.setAttribute("x2", String(w - CHART_PAD.right)); + line.setAttribute("y1", String(y)); + line.setAttribute("y2", String(y)); + svg.append(line); + const tick = document.createElementNS(svgNs, "text"); + tick.setAttribute("class", "ui-chart__tick"); + tick.setAttribute("x", String(CHART_PAD.left - 6)); + tick.setAttribute("y", String(y + 4)); + tick.setAttribute("text-anchor", "end"); + tick.textContent = `${Math.round(v)}${yUnit}`; + svg.append(tick); + } + + // x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드 + if (opts.xLabels && opts.xLabels.length > 0) { + const labels = opts.xLabels; + const baseY = CHART_PAD.top + plotH; + for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) { + const label = labels[idx]; + const x = xAt(idx); + if (label !== undefined && label !== "") { + // 수직 점선 그리드 + const vline = document.createElementNS(svgNs, "line"); + vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical"); + vline.setAttribute("x1", String(x)); + vline.setAttribute("x2", String(x)); + vline.setAttribute("y1", String(CHART_PAD.top)); + vline.setAttribute("y2", String(baseY)); + svg.append(vline); + // x축 라벨 + const tick = document.createElementNS(svgNs, "text"); + tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x"); + tick.setAttribute("x", String(x)); + tick.setAttribute("y", String(baseY + 16)); + tick.setAttribute("text-anchor", "middle"); + tick.textContent = label; + svg.append(tick); + } + } + } + + // 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리) + for (const s of opts.series) { + let segment: { x: number; y: number }[] = []; + let d = ""; + const flush = () => { + if (segment.length > 0) d += `${splinePath(segment)} `; + segment = []; + }; + s.values.forEach((v, i) => { + if (v === null || v === undefined) { + flush(); + return; + } + segment.push({ x: xAt(i), y: yAt(v) }); + }); + flush(); + const path = document.createElementNS(svgNs, "path"); + path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`); + path.setAttribute("d", d.trim()); + svg.append(path); + } + + // 범례 (그래프 영역 우상단 오버레이) + const legend = el("div", { className: "ui-chart__legend" }); + opts.series.forEach((s) => { + legend.append( + el("span", { + className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`, + text: s.name, + }), + ); + }); + + const plot = el("div", { className: "ui-chart__plot" }); + plot.append(svg, legend); + wrap.append(plot); + return wrap; +} + +/* ============================================================================= + * 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles) + * theme.css 변수만 참조. 앱 진입 시 1회 호출. + * ========================================================================== */ + diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts new file mode 100644 index 00000000..99454304 --- /dev/null +++ b/ui_template/ui_template_elements_styles.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * ui_template_elements_styles.ts + * 공통 컴포넌트 기본 스타일 규칙 — injectBaseStyles() 로 1회 주입한다. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 규칙 문자열은 + * 그대로이고, 본체가 다시 내보내므로 호출부는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +const BASE_STYLE_ID = "ui-template-elements-style"; + +const BASE_CSS = ` +[hidden] { display: none !important; } + +/* --- Button --- */ +.ui-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-8); + font-family: var(--font-body); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); + line-height: 1; + border: 1px solid transparent; + border-radius: var(--radius-buttons); + padding: var(--spacing-8) var(--spacing-16); + cursor: pointer; + transition: background-color var(--transition-fast), + border-color var(--transition-fast), color var(--transition-fast); +} +.ui-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.ui-btn__icon { display: inline-flex; width: 16px; height: 16px; } + +.ui-btn--filled { + background-color: var(--color-primary); + color: var(--color-primary-text); + box-shadow: var(--shadow-sm); +} +.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); } + +.ui-btn--ghost { + background-color: transparent; + border-color: var(--color-primary); + color: var(--color-primary); +} +.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); } + +.ui-btn--pill { + background-color: transparent; + color: var(--color-plum-velvet); + border-radius: var(--radius-pills); +} +.ui-btn--pill:hover:not(:disabled), +.ui-btn--pill.is-active { background-color: var(--color-mist-violet); } + +.ui-btn--danger { + background-color: var(--color-danger); + color: var(--color-canvas); +} + +/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ +.ui-btn--glass { + border-color: color-mix(in srgb, var(--color-border) 65%, transparent); + background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + color: var(--color-text-body); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.ui-btn--glass:hover:not(:disabled), +.ui-btn--glass.is-active { + border-color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); + color: var(--color-primary-text); +} + +/* --- Input Field --- */ +.ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } +.ui-field__label { + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); +} +.ui-input { + font-family: var(--font-body); + font-size: var(--text-body-sm); + color: var(--color-text-body); + background-color: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + padding: 10px 14px; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.ui-input::placeholder { color: var(--color-text-muted); } +.ui-input:focus { + outline: none; + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); +} +.ui-input--error { border-color: var(--color-danger); } +.ui-field__error { + font-size: var(--text-caption); + color: var(--color-danger); + min-height: 0; + visibility: hidden; +} +.ui-field__error.is-visible { visibility: visible; } + +.ui-select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + font-family: var(--font-body); + font-size: var(--text-body-sm); + color: var(--color-text-body); + background-color: var(--color-canvas); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + padding: 10px 36px 10px 14px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px; + cursor: pointer; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.ui-select:focus { + outline: none; + border-color: var(--color-focus-ring); + box-shadow: 0 0 0 1px var(--color-focus-ring); +} +.ui-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* --- Card --- */ +.ui-card { + background-color: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + padding: var(--card-padding); + box-shadow: var(--shadow-sm); +} +.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; } +.ui-card__title { + font-family: var(--font-display); + color: var(--color-plum-velvet); + margin-bottom: var(--spacing-16); +} +.ui-card__body { display: flex; flex-direction: column; gap: var(--spacing-16); } + +/* --- Tag / Pill --- */ +.ui-tag { + display: inline-flex; + align-items: center; + border-radius: var(--radius-pills); + padding: var(--spacing-4) var(--spacing-16); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); + line-height: 1.3; +} +.ui-tag--accent { background-color: var(--color-mist-violet); color: var(--color-royal-amethyst); } +.ui-tag--neutral { background-color: var(--color-paper); color: var(--color-slate); } +.ui-tag--success { background-color: var(--color-mist-violet); color: var(--color-success); } +.ui-tag--warning { background-color: var(--color-paper); color: var(--color-warning); } +.ui-tag--danger { background-color: var(--color-paper); color: var(--color-danger); } + +/* --- Loading Overlay + Spinner --- */ +.ui-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background-color: rgba(38, 17, 74, 0.24); + z-index: var(--z-overlay); +} +.ui-overlay.is-active { display: flex; } +.ui-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--color-mist-violet); + border-top-color: var(--color-royal-amethyst); + border-radius: var(--radius-pills); + animation: ui-spin 0.8s linear infinite; +} +@keyframes ui-spin { to { transform: rotate(360deg); } } + +/* --- Toast --- */ +.ui-toast-container { + position: fixed; + top: var(--spacing-24); + right: var(--spacing-24); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + z-index: var(--z-toast); +} +.ui-toast { + padding: var(--spacing-16) var(--spacing-24); + border-radius: var(--radius-cards); + font-size: var(--text-body-sm); + color: var(--color-canvas); + box-shadow: var(--shadow-lg); + opacity: 0; + transform: translateX(16px); + transition: opacity var(--transition-base), transform var(--transition-base); +} +.ui-toast.is-visible { opacity: 1; transform: translateX(0); } +.ui-toast--success { background-color: var(--color-success); } +.ui-toast--error { background-color: var(--color-danger); } +.ui-toast--warning { background-color: var(--color-warning); } +.ui-toast--info { background-color: var(--color-royal-amethyst); } + +/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */ +.ui-confirm { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(38, 17, 74, 0.35); + z-index: var(--z-overlay); +} +.ui-confirm__panel { + min-width: 300px; + max-width: 420px; + padding: var(--spacing-24); + border-radius: var(--radius-cards); + background-color: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} +.ui-confirm__message { + margin: 0 0 var(--spacing-16); + color: var(--color-text-body); + font-size: var(--text-body-sm); + white-space: pre-line; +} +.ui-confirm__actions { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +/* --- Workflow Shell (3단 레이아웃) --- */ +.ui-wf { display: flex; flex-direction: column; height: 100%; } +.ui-wf__header { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--wf-header-height); + padding: 0 var(--spacing-24); + border-bottom: 1px solid var(--color-border); + background-color: var(--color-surface-raised); +} +.ui-wf__title { font-size: var(--text-subheading); } +.ui-wf__body { display: flex; flex: 1; min-height: 0; } +.ui-wf__left { + width: var(--wf-left-panel-width); + flex-shrink: 0; + padding: var(--spacing-24); + border-right: 1px solid var(--color-border); + overflow-y: auto; + background-color: var(--color-surface); +} +.ui-wf__right { + flex: 1; + min-width: 0; + overflow: auto; + background-color: var(--color-bg); +} + +/* --- Line Chart --- */ +.ui-chart { + width: 100%; +} +.ui-chart__plot { + position: relative; + width: 100%; +} +.ui-chart__svg { + width: 100%; + height: auto; + display: block; +} +.ui-chart__empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 120px; + color: var(--color-text-muted); + font-size: var(--text-body); +} +.ui-chart__grid { + stroke: var(--color-border); + stroke-width: 1; + opacity: 0.5; +} +.ui-chart__grid--vertical { + stroke-dasharray: 4, 4; +} +.ui-chart__tick { + fill: var(--color-text-muted); + font-size: 12px; + font-family: var(--font-body); +} +.ui-chart__tick--x { + font-size: 11px; +} +.ui-chart__line { + fill: none; + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} +.ui-chart__line--c0 { stroke: var(--color-chart-0); } +.ui-chart__line--c1 { stroke: var(--color-chart-1); } +.ui-chart__line--c2 { stroke: var(--color-chart-2); } +.ui-chart__line--c3 { stroke: var(--color-chart-3); } +.ui-chart__point { + stroke-width: 1.5; + stroke: var(--color-surface); +} +.ui-chart__point--c0 { fill: var(--color-chart-0); } +.ui-chart__point--c1 { fill: var(--color-chart-1); } +.ui-chart__point--c2 { fill: var(--color-chart-2); } +.ui-chart__point--c3 { fill: var(--color-chart-3); } + +.ui-chart__legend { + position: absolute; + top: var(--spacing-4); + right: var(--spacing-8); + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--spacing-8) var(--spacing-16); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-cards); + background-color: color-mix(in srgb, var(--color-surface) 78%, transparent); + pointer-events: none; +} +.ui-chart__legend-item { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-caption); + color: var(--color-text); +} +.ui-chart__legend-item::before { + content: ""; + width: 12px; + height: 3px; + border-radius: 2px; + background-color: currentColor; +} +.ui-chart__legend-item--c0 { color: var(--color-chart-0); } +.ui-chart__legend-item--c1 { color: var(--color-chart-1); } +.ui-chart__legend-item--c2 { color: var(--color-chart-2); } +.ui-chart__legend-item--c3 { color: var(--color-chart-3); } +`; + +/** 공통 컴포넌트 기본 스타일을 에 1회 주입. 앱 진입 시 호출. */ +export function injectBaseStyles(): void { + if (document.getElementById(BASE_STYLE_ID)) return; + const style = el("style", { attrs: { id: BASE_STYLE_ID } }); + style.textContent = BASE_CSS; + document.head.append(style); +} -- 2.54.0 From f541ab4480bc4ea7e12cd97beb9aa13eced682fc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:19:04 +0900 Subject: [PATCH 081/441] =?UTF-8?q?refactor(B03):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=99=94=EB=A9=B4=20700=EC=A4=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EA=B7=9C=EC=B9=99=C2=B7=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=ED=9D=90=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 925줄 한 파일을 셋으로 나눔 (동작 불변). - `B03_FileInput_UI_Page.ts` 692줄 — 화면 조립·카드 렌더·현황 표시 - `B03_FileInput_UI_Page_Flow.ts` 262줄 — 중단 세션 이어올리기·보관함 이관·청크 업로드· 초기 계산 중 재잠금·서비스워커. 화면 상태는 `UploadFlowContext` 창구로만 받음 - `B03_FileInput_UI_Page_Rules.ts` 107줄 — 필수 카드 판정·파일 적합성·업로드 가능 판정· 서버 파일의 카드 매핑 (상태를 가두지 않는 순수 함수) 검증: 공용 브라우저에서 새 프로젝트 만들어 노선 5종 실제 선택·업로드 — 카드 5장 선택 후 [파일 업로드] 비활성, [LAS 없이 설계] 켜면 활성(판정 규칙 정상), 업로드 후 4장 완료 표시(20초). 남은 `route_prj` 오류·400 은 지형 자료가 없어 분리 전에도 같던 것. `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Page.ts | 303 +++---------------- B03_FileInput/B03_FileInput_UI_Page_Flow.ts | 262 ++++++++++++++++ B03_FileInput/B03_FileInput_UI_Page_Rules.ts | 107 +++++++ 3 files changed, 404 insertions(+), 268 deletions(-) create mode 100644 B03_FileInput/B03_FileInput_UI_Page_Flow.ts create mode 100644 B03_FileInput/B03_FileInput_UI_Page_Rules.ts diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 307eaaf7..281ecb26 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -3,19 +3,14 @@ import { ROUTES, UPLOAD_ALLOWED_EXT, UPLOAD_MAX_FILES, - UPLOAD_MAX_MB, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createButton, createTag, showToast } from "@ui/ui_template_elements"; +import { createButton, createTag } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; import { fetchUploadOverview } from "./B03_FileInput_Api_Fetch"; -import { clearPreloadMark } from "../A00_Common/b_asset_cache"; import { navigateTo } from "../A00_Common/router"; -import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; -import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { createInputGuide } from "./B03_FileInput_UI_Guide"; import { createTempPicker } from "./B03_FileInput_UI_TempPicker"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -25,23 +20,24 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { restoreB03ProjectState } from "./B03_FileInput_State"; +import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow"; +import { + isSlotRequired, + slotForOverviewFile, + validateFileForSlot, + validateSlots, +} from "./B03_FileInput_UI_Page_Rules"; import { readCrsLabel, readExtent, renderSlotPreview, type PreviewExtent, } from "./B03_FileInput_UI_Preview"; -import { - confirmReplaceUpload, - isInitialPipelineRunning, - pollInitialPipeline, - uploadOneFile, -} from "./B03_FileInput_UI_Upload"; +import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, formatBytes, formatEta, - getExtension, initializeSlots, makeSessionKey, planSlotAssignments, @@ -51,7 +47,6 @@ import { TERRAIN_SLOTS, type FileSlot, type FileSlotState, - type StoredUploadSession, type UploadStatus, } from "./B03_FileInput_UI_Support"; import "./B03_FileInput_UI_Style.css"; @@ -80,12 +75,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1" : false; - function clearDerivedCaches(projectId: string): void { - clearRouteLatestCache(projectId); - invalidateSectionDetail(projectId); - clearPreloadMark(); - } - const subtitle = document.createElement("p"); subtitle.className = "b03-file__subtitle"; subtitle.textContent = L("B03_File_Subtitle"); @@ -185,7 +174,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pageError.textContent = ""; return; } - const validation = validateSlots(); + const validation = validateSlots(slots, selectedStates(), activeProjectId, lasFreeDesign); uploadButton.disabled = validation !== null; // 「업로드할 파일을 선택하세요」는 버튼이 잠긴 것으로 이미 드러난다 — 고르기도 전에 // 붉은 경고를 띄우지 않는다(2026-09-03 사용자 지시). 나머지 사유는 그대로 알린다. @@ -198,7 +187,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const extLabel = state.extensions.join(", "); const target = card.querySelector(".b03-file__card-ext"); if (!target) return; - target.textContent = isSlotRequired(state) + target.textContent = isSlotRequired(state, slots, lasFreeDesign) ? extLabel : `${extLabel} · ${L("B03_File_Card_Optional")}`; } @@ -295,14 +284,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { renderSlot(slot); } - function validateFileForSlot(file: File, state: FileSlotState): string | null { - const extension = getExtension(file.name); - const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; - if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); - if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); - return null; - } - async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise { const state = targetSlot ? slots.get(targetSlot) : undefined; if (!state) { @@ -410,48 +391,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { } /** 노선 도형이 shapefile인가 — 로컬 선택과 서버 정본을 함께 본다. */ - function routeIsShapefile(): boolean { - const state = slots.get("csv"); - const name = state?.file?.name ?? state?.serverUploaded?.name; - return getExtension(name ?? "") === ".shp"; - } - - /** - * 이 카드가 지금 필수인가. - * - * shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 — - * CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31). - */ - function isSlotRequired(state: FileSlotState): boolean { - if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(); - // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. - if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; - return state.isRequired; - } - - function validateSlots(): string | null { - if (!activeProjectId) return L("B03_File_Error_Project"); - const selected = selectedStates(); - if (selected.length === 0) return L("B03_File_Error_Required"); - if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); - // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — - // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). - const missingRequired = Array.from(slots.values()).some( - (state) => isSlotRequired(state) && !state.file && !state.serverUploaded, - ); - if (missingRequired) return L("B03_File_Error_RequiredSlots"); - if (!lasFreeDesign) { - const lasState = slots.get("las_laz"); - if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); - } - for (const state of selected) { - if (state.error) return state.error; - const validation = validateFileForSlot(state.file!, state); - if (validation) return validation; - } - return null; - } - /** * 재접속 현황(서버 정본) 적용 — 업로드 완료 파일을 슬롯 카드에 표시하고, 중단된 청크 * 세션은 파일 재선택 전에도 안내하며, 전체 완료면 완료 배지를 띄운다. @@ -467,23 +406,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // id 오름차순이므로 마지막 것이 남는다). for (const state of slots.values()) state.serverUploaded = undefined; for (const file of overview.files) { - const extension = `.${file.file_type.toLowerCase()}`; - // PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 - // 저장 경로로 가린다(2026-08-31). - const inRouteSet = (file.relative_path ?? "").includes("/input/shp/"); - const slot: FileSlot | undefined = - extension === ".prj" - ? inRouteSet - ? "route_prj" - : "prj" - : // 옛 프로젝트의 노선은 `.csv`로 올라가 있다 — 이제 받지는 않지만(2026-09-03) - // 이미 올라간 것은 계획노선 도형 카드에 그대로 보여야 한다. - extension === ".csv" - ? "csv" - : Array.from(slots.values()).find( - (candidate) => - candidate.slot !== "route_prj" && candidate.extensions.includes(extension), - )?.slot; + const slot = slotForOverviewFile(file, slots); const state = slot ? slots.get(slot) : undefined; if (state) { state.serverUploaded = { @@ -576,184 +499,28 @@ export async function renderB03FileInput(root: HTMLElement): Promise { return group; } - async function detectPausedUploads(): Promise { - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - resumeBanner.replaceChildren(); - resumeBanner.classList.remove("is-visible"); - if (!activeProjectId) return; - - for (const state of selectedStates()) { - const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!)); - if (!stored) continue; - const session = JSON.parse(stored) as StoredUploadSession; - state.uploadSessionId = session.uploadSessionId; - state.progressBytes = session.completedChunks * session.chunkSizeBytes; - renderSlot(state.slot); - const text = document.createElement("span"); - text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; - const resume = createButton({ - label: L("B03_File_Resume_Button"), - variant: "ghost", - onClick: () => void startChunkedUpload([state]), - }); - const fresh = createButton({ - label: L("B03_File_New_Button"), - variant: "ghost", - onClick: () => { - localStorage.removeItem(session.key); - state.uploadSessionId = undefined; - state.progressBytes = 0; - renderSlot(state.slot); - resumeBanner.replaceChildren(); - resumeBanner.classList.remove("is-visible"); - }, - }); - resumeBanner.append(text, resume, fresh); - resumeBanner.classList.add("is-visible"); - break; - } - } - - /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ - const pollAnalysis = (projectId: string): Promise => - pollInitialPipeline(projectId, (message) => { - pageError.textContent = message; - showToast(message, "warning"); - }); - - /** - * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. - * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 - * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. - */ - async function attachSelectedTempBatch(): Promise { - const batch = tempPicker.selected(); - if (!batch) return; - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - if (!activeProjectId) { - pageError.textContent = L("B03_File_Error_Project"); - return; - } - pageError.textContent = ""; - try { - const result = await attachTempBatch(activeProjectId, batch.batch_id); - clearDerivedCaches(activeProjectId); - tempPicker.clear(); - showToast(L("B03_Temp_Attach_Success"), "success"); - await applyUploadOverview(); - if (!result.analysis_started) { - showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); - return; - } - showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollAnalysis(activeProjectId); - if (analysisComplete) { - navigateTo(completionRoute); - } else { - showToast(L("B03_File_Analysis_StillRunning"), "warning"); - } - } catch (error) { - const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); - pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; - showToast(L("B03_Temp_Attach_Failed"), "error"); - } - } - - /** - * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. - * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. - */ - async function relockWhileInitialPipelineRuns(): Promise { - if (!activeProjectId || isUploading) return; - try { - const state = await fetchWorkflowState(activeProjectId); - if (!isInitialPipelineRunning(state)) return; - } catch { - return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. - } - setUploading(true); - showToast(L("B03_File_Analysis_InProgress"), "info"); - try { - const done = await pollAnalysis(activeProjectId); - if (done) showToast(L("B03_File_Upload_Success"), "success"); - } finally { - setUploading(false); - void applyUploadOverview(); - } - } - - async function startChunkedUpload(targetStates = selectedStates()): Promise { - if (isUploading) return; - // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. - if (tempPicker.selected()) { - setUploading(true); - try { - await attachSelectedTempBatch(); - } finally { - setUploading(false); - } - return; - } - activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - const validation = validateSlots(); - if (validation) { - pageError.textContent = validation; - return; - } - - pageError.textContent = ""; - setUploading(true); - try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; - await uploadOneFile( - activeProjectId, - state, - index === targetStates.length - 1, - () => renderSlot(state.slot), - lasFreeDesign, - ); - } - clearDerivedCaches(activeProjectId); - // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 - // 않는다(2026-09-03 사용자 지시). - showToast(L("B03_File_Upload_Success"), "success"); - - showToast(L("B03_File_Analysis_InProgress"), "info"); - const analysisComplete = await pollAnalysis(activeProjectId); - - if (analysisComplete) { - navigateTo(completionRoute); - } else { - showToast(L("B03_File_Analysis_StillRunning"), "warning"); - } - } catch (error) { - const failed = targetStates.find((state) => state.uploadStatus === "uploading"); - const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); - if (failed) showErrorMessage(failed.slot, detail); - pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; - showToast(L("B03_File_Upload_Failed"), "error"); - } finally { - setUploading(false); - } - } - - async function registerB03ServiceWorker(): Promise { - if (!("serviceWorker" in navigator)) { - showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); - return; - } - try { - const registration = await navigator.serviceWorker.register( - new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), - { type: "module" }, - ); - registration.active?.postMessage({ type: "B03_SW_PING" }); - showToast(L("B03_File_ServiceWorker_Ready"), "info"); - } catch { - showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); - } - } + // 업로드 흐름(중단 세션·보관함 이관·청크 업로드·재잠금·서비스워커)은 파일이 + // 700줄을 넘어 떼어냈다(2026-09-04). 화면 상태는 아래 창구로만 넘긴다. + const flow = createUploadFlow({ + projectId: () => activeProjectId, + setProjectId: (value) => { + activeProjectId = value; + }, + slots, + selectedStates, + lasFreeDesign: () => lasFreeDesign, + isUploading: () => isUploading, + setUploading, + renderSlot, + showErrorMessage, + applyUploadOverview, + tempPicker, + pageError, + resumeBanner, + completionRoute, + }); + const { detectPausedUploads, pollAnalysis, relockWhileInitialPipelineRuns, startChunkedUpload } = + flow; function onB03_File_Select_Change(): void { onFileSelected(fileInput.files ? Array.from(fileInput.files) : []); @@ -910,7 +677,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { for (const slot of slots.keys()) renderSlot(slot); applyLasFreeState(); void relockWhileInitialPipelineRuns(); - void registerB03ServiceWorker(); + void flow.registerB03ServiceWorker(); void applyUploadOverview(); void detectPausedUploads(); if (activeProjectId) { diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts new file mode 100644 index 00000000..eeda5ac1 --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -0,0 +1,262 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Flow.ts + * 파일 입력 화면의 업로드 흐름 — 중단 세션 이어올리기, 보관함 자료 옮기기, + * 청크 업로드 시작, 초기 계산 중 재잠금, 서비스워커 등록. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면이 들고 있는 상태는 `ctx` 로 받아 쓰기만 한다 — 동작·순서는 종전과 같다. + * ========================================================================== */ + +import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { navigateTo } from "../A00_Common/router"; +import { clearPreloadMark } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp"; +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { validateSlots } from "./B03_FileInput_UI_Page_Rules"; +import { + isInitialPipelineRunning, + pollInitialPipeline, + uploadOneFile, +} from "./B03_FileInput_UI_Upload"; +import { + makeSessionKey, + type FileSlot, + type FileSlotState, + type StoredUploadSession, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 화면이 들고 있는 상태·조작을 흐름 쪽에 넘겨 주는 창구. */ +export interface UploadFlowContext { + projectId: () => string; + setProjectId: (value: string) => void; + slots: Map; + selectedStates: () => FileSlotState[]; + lasFreeDesign: () => boolean; + isUploading: () => boolean; + setUploading: (busy: boolean) => void; + renderSlot: (slot: FileSlot) => void; + showErrorMessage: (slot: FileSlot, error: string) => void; + applyUploadOverview: () => Promise; + tempPicker: { selected: () => { batch_id: string } | null; clear: () => void }; + pageError: HTMLElement; + resumeBanner: HTMLElement; + completionRoute: RoutePath; +} + +export interface UploadFlowHandle { + /** 분석 대기 — 재접속 복원 표시에서도 쓴다. */ + pollAnalysis: (projectId: string) => Promise; + detectPausedUploads: () => Promise; + relockWhileInitialPipelineRuns: () => Promise; + startChunkedUpload: (targetStates?: FileSlotState[]) => Promise; + registerB03ServiceWorker: () => Promise; +} + +/** 새 자료가 들어오면 이 프로젝트로 만들어 둔 파생 캐시를 버린다. */ +function clearDerivedCaches(projectId: string): void { + clearRouteLatestCache(projectId); + invalidateSectionDetail(projectId); + clearPreloadMark(); +} + +export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { + async function detectPausedUploads(): Promise { + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + if (!ctx.projectId()) return; + + for (const state of ctx.selectedStates()) { + const stored = localStorage.getItem(makeSessionKey(ctx.projectId(), state.file!)); + if (!stored) continue; + const session = JSON.parse(stored) as StoredUploadSession; + state.uploadSessionId = session.uploadSessionId; + state.progressBytes = session.completedChunks * session.chunkSizeBytes; + ctx.renderSlot(state.slot); + const text = document.createElement("span"); + text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; + const resume = createButton({ + label: L("B03_File_Resume_Button"), + variant: "ghost", + onClick: () => void startChunkedUpload([state]), + }); + const fresh = createButton({ + label: L("B03_File_New_Button"), + variant: "ghost", + onClick: () => { + localStorage.removeItem(session.key); + state.uploadSessionId = undefined; + state.progressBytes = 0; + ctx.renderSlot(state.slot); + ctx.resumeBanner.replaceChildren(); + ctx.resumeBanner.classList.remove("is-visible"); + }, + }); + ctx.resumeBanner.append(text, resume, fresh); + ctx.resumeBanner.classList.add("is-visible"); + break; + } + } + + /** 분석 대기 — 자동 확정이 보류되면 그 사유를 화면과 알림으로 남긴다. */ + const pollAnalysis = (projectId: string): Promise => + pollInitialPipeline(projectId, (message: string) => { + ctx.pageError.textContent = message; + showToast(message, "warning"); + }); + + /** + * 보관함 자료를 이 프로젝트로 옮기고 초기 분석까지 이어 간다. + * 파일을 직접 고른 게 아니라 이미 서버에 있는 자료를 옮기는 것이라 청크 업로드를 타지 + * 않는다 — 이동이 끝나면 같은 분석 대기 흐름으로 합류한다. + */ + async function attachSelectedTempBatch(): Promise { + const batch = ctx.tempPicker.selected(); + if (!batch) return; + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + if (!ctx.projectId()) { + ctx.pageError.textContent = L("B03_File_Error_Project"); + return; + } + ctx.pageError.textContent = ""; + try { + const result = await attachTempBatch(ctx.projectId(), batch.batch_id); + clearDerivedCaches(ctx.projectId()); + ctx.tempPicker.clear(); + showToast(L("B03_Temp_Attach_Success"), "success"); + await ctx.applyUploadOverview(); + if (!result.analysis_started) { + showToast(L("B03_Temp_Attach_NoAnalysis"), "warning"); + return; + } + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed"); + ctx.pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`; + showToast(L("B03_Temp_Attach_Failed"), "error"); + } + } + + /** + * 새로고침·재진입해도 초기 자동 계산이 도는 중이면 업로드 버튼을 다시 잠근다. + * 잠금이 화면 상태로만 남아 있으면 새로고침 한 번으로 풀려 자료를 겹쳐 올릴 수 있다. + */ + async function relockWhileInitialPipelineRuns(): Promise { + if (!ctx.projectId() || ctx.isUploading()) return; + try { + const state = await fetchWorkflowState(ctx.projectId()); + if (!isInitialPipelineRunning(state)) return; + } catch { + return; // 상태를 못 읽으면 잠그지 않는다 — 서버가 막아 준다. + } + ctx.setUploading(true); + showToast(L("B03_File_Analysis_InProgress"), "info"); + try { + const done = await pollAnalysis(ctx.projectId()); + if (done) showToast(L("B03_File_Upload_Success"), "success"); + } finally { + ctx.setUploading(false); + void ctx.applyUploadOverview(); + } + } + + async function startChunkedUpload(targetStates = ctx.selectedStates()): Promise { + if (ctx.isUploading()) return; + // 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다. + if (ctx.tempPicker.selected()) { + ctx.setUploading(true); + try { + await attachSelectedTempBatch(); + } finally { + ctx.setUploading(false); + } + return; + } + ctx.setProjectId(localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""); + const validation = validateSlots( + ctx.slots, + ctx.selectedStates(), + ctx.projectId(), + ctx.lasFreeDesign(), + ); + if (validation) { + ctx.pageError.textContent = validation; + return; + } + + ctx.pageError.textContent = ""; + ctx.setUploading(true); + try { + for (let index = 0; index < targetStates.length; index += 1) { + const state = targetStates[index]; + await uploadOneFile( + ctx.projectId(), + state, + index === targetStates.length - 1, + () => ctx.renderSlot(state.slot), + ctx.lasFreeDesign(), + ); + } + clearDerivedCaches(ctx.projectId()); + // 업로드 결과는 카드가 이미 완료 상태로 보여 준다 — 같은 내용을 목록으로 또 쌓지 + // 않는다(2026-09-03 사용자 지시). + showToast(L("B03_File_Upload_Success"), "success"); + + showToast(L("B03_File_Analysis_InProgress"), "info"); + const analysisComplete = await pollAnalysis(ctx.projectId()); + + if (analysisComplete) { + navigateTo(ctx.completionRoute); + } else { + showToast(L("B03_File_Analysis_StillRunning"), "warning"); + } + } catch (error) { + const failed = targetStates.find((state) => state.uploadStatus === "uploading"); + const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed"); + if (failed) ctx.showErrorMessage(failed.slot, detail); + ctx.pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`; + showToast(L("B03_File_Upload_Failed"), "error"); + } finally { + ctx.setUploading(false); + } + } + + async function registerB03ServiceWorker(): Promise { + if (!("serviceWorker" in navigator)) { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + return; + } + try { + const registration = await navigator.serviceWorker.register( + new URL("./B03_FileInput_ServiceWorker.ts", import.meta.url), + { type: "module" }, + ); + registration.active?.postMessage({ type: "B03_SW_PING" }); + showToast(L("B03_File_ServiceWorker_Ready"), "info"); + } catch { + showToast(L("B03_File_ServiceWorker_Unavailable"), "warning"); + } + } + + return { + pollAnalysis, + detectPausedUploads, + relockWhileInitialPipelineRuns, + startChunkedUpload, + registerB03ServiceWorker, + }; +} diff --git a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts new file mode 100644 index 00000000..54e0ab3a --- /dev/null +++ b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B03_FileInput_UI_Page_Rules.ts + * 파일 입력 화면의 판정 규칙 — 어떤 카드가 지금 필수인가, 고른 파일이 그 자리에 맞는가, + * 업로드를 시작해도 되는가, 서버 파일이 어느 카드로 가는가. + * + * 화면 조립(`B03_FileInput_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * 화면 상태를 가두지 않고 **인자로 받는 순수 함수**만 둔다 — 판정 결과는 종전과 같다. + * ========================================================================== */ + +import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; +import { + getExtension, + SHAPEFILE_DEPENDENT_SLOTS, + TERRAIN_SLOTS, + type FileSlot, + type FileSlotState, +} from "./B03_FileInput_UI_Support"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +export type SlotMap = Map; + +/** 노선 도형 카드에 shapefile이 들어와 있는가. */ +export function routeIsShapefile(slots: SlotMap): boolean { + const state = slots.get("csv"); + const name = state?.file?.name ?? state?.serverUploaded?.name; + return getExtension(name ?? "") === ".shp"; +} + +/** + * 이 카드가 지금 필수인가. + * + * shapefile 형제 카드(.shx/.dbf/노선 .prj)는 노선 도형이 shapefile일 때만 필수다 — + * CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31). + */ +export function isSlotRequired( + state: FileSlotState, + slots: SlotMap, + lasFreeDesign: boolean, +): boolean { + if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile(slots); + // LAS 없이 설계면 지형 자료(포인트클라우드·좌표계·래스터)는 통째로 받지 않는다. + if (TERRAIN_SLOTS.includes(state.slot)) return lasFreeDesign ? false : state.isRequired; + return state.isRequired; +} + +/** 고른 파일이 그 카드의 확장자·크기 규칙에 맞는가. 어긋나면 안내 문구를 돌려준다. */ +export function validateFileForSlot(file: File, state: FileSlotState): string | null { + const extension = getExtension(file.name); + const maxBytes = UPLOAD_MAX_MB * 1024 * 1024; + if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType"); + if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size"); + return null; +} + +/** 업로드를 시작해도 되는가. 안 되면 첫 번째 사유를 돌려준다. */ +export function validateSlots( + slots: SlotMap, + selected: FileSlotState[], + activeProjectId: string, + lasFreeDesign: boolean, +): string | null { + if (!activeProjectId) return L("B03_File_Error_Project"); + if (selected.length === 0) return L("B03_File_Error_Required"); + if (selected.length > UPLOAD_MAX_FILES) return L("B03_File_Error_Count"); + // 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 — + // 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시). + const missingRequired = Array.from(slots.values()).some( + (state) => isSlotRequired(state, slots, lasFreeDesign) && !state.file && !state.serverUploaded, + ); + if (missingRequired) return L("B03_File_Error_RequiredSlots"); + if (!lasFreeDesign) { + const lasState = slots.get("las_laz"); + if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las"); + } + for (const state of selected) { + if (state.error) return state.error; + const validation = validateFileForSlot(state.file!, state); + if (validation) return validation; + } + return null; +} + +/** + * 서버 현황의 파일 한 건이 어느 카드로 가는가. + * + * PRJ 두 장은 확장자가 같다 — 노선 세트는 `input/shp/`에 모여 있으므로 저장 경로로 + * 가린다(2026-08-31). 옛 프로젝트의 노선은 `.csv`로 올라가 있어 계획노선 도형 카드로 + * 보낸다(이제 새로 받지는 않는다, 2026-09-03). + */ +export function slotForOverviewFile( + file: UploadOverviewFile, + slots: SlotMap, +): FileSlot | undefined { + const extension = `.${file.file_type.toLowerCase()}`; + if (extension === ".prj") { + return (file.relative_path ?? "").includes("/input/shp/") ? "route_prj" : "prj"; + } + if (extension === ".csv") return "csv"; + return Array.from(slots.values()).find( + (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), + )?.slot; +} -- 2.54.0 From 887444fe7cd92510123d59cbcfe1d409f51f3bd3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:30:22 +0900 Subject: [PATCH 082/441] =?UTF-8?q?refactor(common=5Futil):=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=EC=84=A4=EA=B3=84=20=EA=B3=84=EC=82=B0=20700?= =?UTF-8?q?=EC=A4=84=20=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=EB=8B=A8=EB=A9=B4=20=EA=B8=B0=ED=95=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 728줄 한 파일을 둘로 나눔 (계산 불변, 순수 이동). - `common_util_cross_design.ts` 405줄 — 표준단면 해석·설계선 조립·면적 산출 - `common_util_cross_design_geometry.ts` 333줄 — `SectionGeometry`(노면·측구·사면 설계고) 와 그것만 쓰는 상수(행진 간격·최대 거리)·`sideRole`·`ResolvedGroup` 파이썬 짝 파일(`B06_Section_Engine_Design.py`)은 손대지 않음 — 미러 관계 유지. 검증: 공용 브라우저 B06 횡단 카드 실측과 서버 계산값 대조 — 측점 0에서 절토 화면 2.50㎡ / 서버 2.4985㎡, 성토 화면 1.85㎡ / 서버 1.8521㎡ 로 일치 (카드 273장 렌더, 콘솔 오류 0). `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_cross_design.ts | 327 +---------------- .../common_util_cross_design_geometry.ts | 333 ++++++++++++++++++ 2 files changed, 335 insertions(+), 325 deletions(-) create mode 100644 common_util/common_util_cross_design_geometry.ts diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 730670a5..3aaeebee 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -24,6 +24,8 @@ * ========================================================================== */ import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; +// 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). +import { SectionGeometry, type ResolvedGroup } from "./common_util_cross_design_geometry"; /** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ const GROUND_TYPE_PRESET: Record = { @@ -39,10 +41,6 @@ const DITCH_TYPES = ["standard", "l_type"]; /** 사면이 원지반과 만났다고 볼 높이차(m). 짝: `_SLOPE_CLOSE_TOLERANCE_M`. */ const SLOPE_CLOSE_TOLERANCE_M = 0.01; -/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ -const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; -const CROSS_MAX_M = 500; export interface CrossGroundSample { offset_m?: number | null; @@ -131,21 +129,6 @@ function asFloat(value: unknown, fallback: number): number { return parsed >= 0 ? parsed : fallback; } -interface ResolvedGroup { - road_width_m: number; - shoulder_left_m: number; - shoulder_right_m: number; - ditch_top_width_m: number; - ditch_bottom_width_m: number; - ditch_depth_m: number; - l_ditch_width_m: number; - l_ditch_depth_m: number; - cross_slope_pct: number; - fill_slope_ratio: number; - cut_slope_ratio: number; - pavement_thickness_m: number; -} - /** * 짝: `_resolve_group`. 파이썬은 config 위에 패널 편집값을 덮지만, 여기서는 이미 그렇게 * 합쳐진 한 벌(`standard`)을 받는다 — config 수치를 프론트에 복제하지 않기 위함이다. @@ -176,15 +159,6 @@ function resolveGroup(presetKey: string, standard: StandardCrossSectionSpec): Re }; } -/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ -function sideRole(sectionMode: string): [string, string] { - if (sectionMode === "left_cut") return ["cut", "fill"]; - if (sectionMode === "right_cut") return ["fill", "cut"]; - if (sectionMode === "both_cut") return ["cut", "cut"]; - if (sectionMode === "both_fill") return ["fill", "fill"]; - throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); -} - /** 짝: `_resolve_ditch_side`. */ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefined): string { if (sectionMode === "left_cut") return "left"; @@ -220,303 +194,6 @@ function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) = }; } -/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ -class SectionGeometry { - halfRoad: number; - leftExtent: number; - rightExtent: number; - zCenter: number; - cutRatio: number; - fillRatio: number; - leftRole: string; - rightRole: string; - ditchSide: string; - soilCutRatio: number; - twoStage: boolean; - ditchType: string; - slopePerOffset: number; - hasDitch: boolean; - ditchPoints: Array<[number, number]> = []; - private groundAt: ((offsetM: number) => number) | null; - private rockOffset: number; - private rockKnee = new Map(); - private cutCross = new Map(); - private fillCross = new Map(); - - constructor(params: { - designElevationM: number; - group: ResolvedGroup; - sectionMode: string; - ditchSide: string; - ditchType: string; - crossSlopePct: number; - groundAt: ((offsetM: number) => number) | null; - soilCutRatio: number | null; - rockBoundaryOffsetM: number | null; - twoStageSlope: boolean; - ditchEnabled: boolean | null; - }) { - const { group } = params; - const halfRoad = group.road_width_m / 2; - this.halfRoad = halfRoad; - this.leftExtent = halfRoad + group.shoulder_left_m; - this.rightExtent = halfRoad + group.shoulder_right_m; - this.zCenter = params.designElevationM; - this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); - this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); - [this.leftRole, this.rightRole] = sideRole(params.sectionMode); - this.ditchSide = params.ditchSide; - this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); - this.twoStage = Boolean( - params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, - ); - this.groundAt = params.groundAt; - this.rockOffset = params.rockBoundaryOffsetM ?? 0; - this.ditchType = params.ditchType; - // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). - const slope = params.crossSlopePct / 100; - this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; - - // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. - const groundAt = params.groundAt; - if (groundAt !== null) { - this.leftRole = - groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; - this.rightRole = - groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; - } - - // 측구 생성 여부(D-1). - if (params.sectionMode === "both_fill") { - this.hasDitch = false; - } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { - this.hasDitch = params.ditchEnabled; - } else if (groundAt !== null) { - const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; - this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; - } else { - this.hasDitch = true; - } - - // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). - const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; - const outward = params.ditchSide === "left" ? 1 : -1; - const edgeZ = this.roadZ(edgeOffset); - if (this.hasDitch) { - if (params.ditchType === "l_type") { - // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. - const width = group.l_ditch_width_m; - const depth = group.l_ditch_depth_m; - this.ditchPoints = [ - [edgeOffset, edgeZ], - [edgeOffset + outward * width, edgeZ - depth], - [edgeOffset + outward * width, edgeZ], - ]; - } else { - // 일반: 상단폭/저폭/깊이 사다리꼴. - const top = group.ditch_top_width_m; - const bottom = Math.min(group.ditch_bottom_width_m, top); - const depth = group.ditch_depth_m; - const inset = (top - bottom) / 2; - this.ditchPoints = [ - [edgeOffset, edgeZ], - [edgeOffset + outward * inset, edgeZ - depth], - [edgeOffset + outward * (inset + bottom), edgeZ - depth], - [edgeOffset + outward * top, edgeZ], - ]; - } - } - } - - /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ - roadZ(offsetM: number): number { - return this.zCenter + this.slopePerOffset * offsetM; - } - - /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ - private slopeStart(side: string): [number, number] { - const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; - const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); - if (side === this.ditchSide && this.ditchPoints.length) { - const outer = this.ditchPoints[this.ditchPoints.length - 1]; - return [Math.abs(outer[0]), outer[1]]; - } - return [edgeOffset, edgeZ]; - } - - /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ - private rockBoundaryZ(side: string, dist: number): number { - const signed = side === "left" ? dist : -dist; - return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; - } - - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); - if (cached !== undefined) return cached; - const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; - } - - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ - private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; - } - - /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ - cutCrossDist(side: string): number | null { - const cached = this.cutCross.get(side); - if (cached !== undefined) return cached; - let result: number | null = null; - if (this.groundAt !== null) { - const [startDist] = this.slopeStart(side); - let dist = startDist; - const maxDist = startDist + CROSS_MAX_M; - while (dist <= maxDist) { - const signed = side === "left" ? dist : -dist; - if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { - result = dist; - break; - } - dist += MARCH_STEP_M; - } - } - this.cutCross.set(side, result); - return result; - } - - /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ - fillCrossDist(side: string): number | null { - const cached = this.fillCross.get(side); - if (cached !== undefined) return cached; - let result: number | null = null; - if (this.groundAt !== null) { - const [startDist, startZ] = this.slopeStart(side); - let dist = startDist; - const maxDist = startDist + CROSS_MAX_M; - while (dist <= maxDist) { - const signed = side === "left" ? dist : -dist; - const fillLine = startZ - (dist - startDist) / this.fillRatio; - if (fillLine - this.groundAt(signed) <= 0) { - result = dist; - break; - } - dist += MARCH_STEP_M; - } - } - this.fillCross.set(side, result); - return result; - } - - /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ - fillGroundSlope(): number | null { - if (this.groundAt === null) return null; - const slopes: number[] = []; - for (const side of ["left", "right"]) { - const role = side === "left" ? this.leftRole : this.rightRole; - if (role !== "fill") continue; - const [startDist] = this.slopeStart(side); - const endDist = this.fillCrossDist(side) ?? startDist + 10; - const run = endDist - startDist; - if (run <= 1e-6) continue; - const sign = side === "left" ? 1 : -1; - const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); - slopes.push(rise / run); - } - return slopes.length ? Math.min(...slopes) : null; - } - - /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ - designZ(offsetM: number, groundM: number): number { - const side = offsetM >= 0 ? "left" : "right"; - const extent = side === "left" ? this.leftExtent : this.rightExtent; - if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); - // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). - if (side === this.ditchSide && this.ditchPoints.length) { - const inner = Math.abs(this.ditchPoints[0][0]); - const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); - if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { - const points = this.ditchPoints; - for (let index = 1; index < points.length; index += 1) { - const x0 = Math.abs(points[index - 1][0]); - const z0 = points[index - 1][1]; - const x1 = Math.abs(points[index][0]); - const z1 = points[index][1]; - if (Math.abs(offsetM) > x1 + 1e-9) continue; - const span = x1 - x0; - if (span <= 1e-9) return z1; - return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); - } - return points[points.length - 1][1]; - } - } - const role = side === "left" ? this.leftRole : this.rightRole; - const [startDist, startZ] = this.slopeStart(side); - const dist = Math.abs(offsetM); - const run = dist - startDist; - if (role === "cut") { - const cross = this.cutCrossDist(side); - if (cross !== null && dist >= cross) return groundM; - return Math.min(this.cutSlopeZ(side, dist), groundM); - } - const cross = this.fillCrossDist(side); - if (cross !== null && dist >= cross) return groundM; - return Math.max(startZ - run / this.fillRatio, groundM); - } - - /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ - breakpoints(): number[] { - const points = [0, this.leftExtent, -this.rightExtent]; - for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { - for (const side of ["left", "right"]) { - const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); - } - } - for (const side of ["left", "right"]) { - const role = side === "left" ? this.leftRole : this.rightRole; - const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); - if (cross !== null) points.push(side === "left" ? cross : -cross); - } - return points; - } -} - /** * 짝: `compute_cross_design`. 측점 하나의 표준횡단 설계선과 절·성토 단면적을 낸다. * diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts new file mode 100644 index 00000000..466ead9b --- /dev/null +++ b/common_util/common_util_cross_design_geometry.ts @@ -0,0 +1,333 @@ +/* ============================================================================= + * common_util_cross_design_geometry.ts + * 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다. + * + * ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다. + * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. + * ========================================================================== */ + +/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ +const MARCH_STEP_M = 0.05; +const KNEE_MAX_M = 200; +const CROSS_MAX_M = 500; + +export interface ResolvedGroup { + road_width_m: number; + shoulder_left_m: number; + shoulder_right_m: number; + ditch_top_width_m: number; + ditch_bottom_width_m: number; + ditch_depth_m: number; + l_ditch_width_m: number; + l_ditch_depth_m: number; + cross_slope_pct: number; + fill_slope_ratio: number; + cut_slope_ratio: number; + pavement_thickness_m: number; +} + +/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */ +export function sideRole(sectionMode: string): [string, string] { + if (sectionMode === "left_cut") return ["cut", "fill"]; + if (sectionMode === "right_cut") return ["fill", "cut"]; + if (sectionMode === "both_cut") return ["cut", "cut"]; + if (sectionMode === "both_fill") return ["fill", "fill"]; + throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); +} + +/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ +export class SectionGeometry { + halfRoad: number; + leftExtent: number; + rightExtent: number; + zCenter: number; + cutRatio: number; + fillRatio: number; + leftRole: string; + rightRole: string; + ditchSide: string; + soilCutRatio: number; + twoStage: boolean; + ditchType: string; + slopePerOffset: number; + hasDitch: boolean; + ditchPoints: Array<[number, number]> = []; + private groundAt: ((offsetM: number) => number) | null; + private rockOffset: number; + private rockKnee = new Map(); + private cutCross = new Map(); + private fillCross = new Map(); + + constructor(params: { + designElevationM: number; + group: ResolvedGroup; + sectionMode: string; + ditchSide: string; + ditchType: string; + crossSlopePct: number; + groundAt: ((offsetM: number) => number) | null; + soilCutRatio: number | null; + rockBoundaryOffsetM: number | null; + twoStageSlope: boolean; + ditchEnabled: boolean | null; + }) { + const { group } = params; + const halfRoad = group.road_width_m / 2; + this.halfRoad = halfRoad; + this.leftExtent = halfRoad + group.shoulder_left_m; + this.rightExtent = halfRoad + group.shoulder_right_m; + this.zCenter = params.designElevationM; + this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); + this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); + [this.leftRole, this.rightRole] = sideRole(params.sectionMode); + this.ditchSide = params.ditchSide; + this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6); + this.twoStage = Boolean( + params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, + ); + this.groundAt = params.groundAt; + this.rockOffset = params.rockBoundaryOffsetM ?? 0; + this.ditchType = params.ditchType; + // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). + const slope = params.crossSlopePct / 100; + this.slopePerOffset = params.ditchSide === "left" ? -slope : slope; + + // 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토. + const groundAt = params.groundAt; + if (groundAt !== null) { + this.leftRole = + groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill"; + this.rightRole = + groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill"; + } + + // 측구 생성 여부(D-1). + if (params.sectionMode === "both_fill") { + this.hasDitch = false; + } else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) { + this.hasDitch = params.ditchEnabled; + } else if (groundAt !== null) { + const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3; + } else { + this.hasDitch = true; + } + + // 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). + const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent; + const outward = params.ditchSide === "left" ? 1 : -1; + const edgeZ = this.roadZ(edgeOffset); + if (this.hasDitch) { + if (params.ditchType === "l_type") { + // L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. + const width = group.l_ditch_width_m; + const depth = group.l_ditch_depth_m; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * width, edgeZ - depth], + [edgeOffset + outward * width, edgeZ], + ]; + } else { + // 일반: 상단폭/저폭/깊이 사다리꼴. + const top = group.ditch_top_width_m; + const bottom = Math.min(group.ditch_bottom_width_m, top); + const depth = group.ditch_depth_m; + const inset = (top - bottom) / 2; + this.ditchPoints = [ + [edgeOffset, edgeZ], + [edgeOffset + outward * inset, edgeZ - depth], + [edgeOffset + outward * (inset + bottom), edgeZ - depth], + [edgeOffset + outward * top, edgeZ], + ]; + } + } + } + + /** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */ + roadZ(offsetM: number): number { + return this.zCenter + this.slopePerOffset * offsetM; + } + + /** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */ + private slopeStart(side: string): [number, number] { + const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent; + const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent); + if (side === this.ditchSide && this.ditchPoints.length) { + const outer = this.ditchPoints[this.ditchPoints.length - 1]; + return [Math.abs(outer[0]), outer[1]]; + } + return [edgeOffset, edgeZ]; + } + + /** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */ + private rockBoundaryZ(side: string, dist: number): number { + const signed = side === "left" ? dist : -dist; + return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; + } + + /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ + knee(side: string): [number, number] | null { + if (!this.twoStage) return null; + const cached = this.rockKnee.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + let diffPrev = startZ - this.rockBoundaryZ(side, startDist); + let result: [number, number] | null; + if (diffPrev >= 0) { + result = [startDist, startZ]; // 시작부터 토사(경계 위) + } else { + result = null; + let distPrev = startDist; + let dist = startDist + MARCH_STEP_M; + while (dist <= startDist + KNEE_MAX_M) { + const zRock = startZ + (dist - startDist) / this.cutRatio; + const diff = zRock - this.rockBoundaryZ(side, dist); + if (diff >= 0) { + const span = diff - diffPrev; + const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; + const kneeDist = distPrev + (dist - distPrev) * ratio; + const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; + result = [kneeDist, kneeZ]; + break; + } + distPrev = dist; + diffPrev = diff; + dist += MARCH_STEP_M; + } + } + this.rockKnee.set(side, result); + return result; + } + + /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + private cutSlopeZ(side: string, dist: number): number { + const [startDist, startZ] = this.slopeStart(side); + const knee = this.twoStage ? this.knee(side) : null; + if (knee !== null) { + const [kneeDist, kneeZ] = knee; + if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; + return kneeZ + (dist - kneeDist) / this.soilCutRatio; + } + return startZ + (dist - startDist) / this.cutRatio; + } + + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ + cutCrossDist(side: string): number | null { + const cached = this.cutCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.cutCross.set(side, result); + return result; + } + + /** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */ + fillCrossDist(side: string): number | null { + const cached = this.fillCross.get(side); + if (cached !== undefined) return cached; + let result: number | null = null; + if (this.groundAt !== null) { + const [startDist, startZ] = this.slopeStart(side); + let dist = startDist; + const maxDist = startDist + CROSS_MAX_M; + while (dist <= maxDist) { + const signed = side === "left" ? dist : -dist; + const fillLine = startZ - (dist - startDist) / this.fillRatio; + if (fillLine - this.groundAt(signed) <= 0) { + result = dist; + break; + } + dist += MARCH_STEP_M; + } + } + this.fillCross.set(side, result); + return result; + } + + /** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */ + fillGroundSlope(): number | null { + if (this.groundAt === null) return null; + const slopes: number[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const [startDist] = this.slopeStart(side); + const endDist = this.fillCrossDist(side) ?? startDist + 10; + const run = endDist - startDist; + if (run <= 1e-6) continue; + const sign = side === "left" ? 1 : -1; + const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist)); + slopes.push(rise / run); + } + return slopes.length ? Math.min(...slopes) : null; + } + + /** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */ + designZ(offsetM: number, groundM: number): number { + const side = offsetM >= 0 ? "left" : "right"; + const extent = side === "left" ? this.leftExtent : this.rightExtent; + if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM); + // 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). + if (side === this.ditchSide && this.ditchPoints.length) { + const inner = Math.abs(this.ditchPoints[0][0]); + const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]); + if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) { + const points = this.ditchPoints; + for (let index = 1; index < points.length; index += 1) { + const x0 = Math.abs(points[index - 1][0]); + const z0 = points[index - 1][1]; + const x1 = Math.abs(points[index][0]); + const z1 = points[index][1]; + if (Math.abs(offsetM) > x1 + 1e-9) continue; + const span = x1 - x0; + if (span <= 1e-9) return z1; + return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span); + } + return points[points.length - 1][1]; + } + } + const role = side === "left" ? this.leftRole : this.rightRole; + const [startDist, startZ] = this.slopeStart(side); + const dist = Math.abs(offsetM); + const run = dist - startDist; + if (role === "cut") { + const cross = this.cutCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.min(this.cutSlopeZ(side, dist), groundM); + } + const cross = this.fillCrossDist(side); + if (cross !== null && dist >= cross) return groundM; + return Math.max(startZ - run / this.fillRatio, groundM); + } + + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ + breakpoints(): number[] { + const points = [0, this.leftExtent, -this.rightExtent]; + for (const [offset] of this.ditchPoints) points.push(offset); + if (this.twoStage) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const knee = role === "cut" ? this.knee(side) : null; + if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + } + } + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); + if (cross !== null) points.push(side === "left" ? cross : -cross); + } + return points; + } +} -- 2.54.0 From 907cfc144bd506b716c7fe9507a8812de6d7a9e8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:33:51 +0900 Subject: [PATCH 083/441] =?UTF-8?q?refactor(B07):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=ED=99=94=EB=A9=B4=20700=EC=A4=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 732줄 한 파일을 둘로 나눔 (동작 불변, 순수 이동). - `B07_DesignDetail_UI_Page.ts` 517줄 — CAD 임베드·postMessage 교신·저장/확정 흐름 - `B07_DesignDetail_UI_Panels.ts` 234줄 — 도면 목록 12분류(`DRAWING_GROUPS`)와 측점 설계 정보 카드. DOM 만 만들고 고르기 동작은 콜백으로 받음 검증: 공용 브라우저 B07 화면 실측 — 도면 목록 패널 1개·항목 54개(「설계 도면 49건」), CAD iframe 1개 로드, 콘솔 오류 0. `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 229 +---------------- .../B07_DesignDetail_UI_Panels.ts | 234 ++++++++++++++++++ 2 files changed, 241 insertions(+), 222 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_UI_Panels.ts diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 8fd49e2a..1615d984 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -11,7 +11,6 @@ * ========================================================================== */ import "./B07_DesignDetail_UI_Style.css"; -import { attachCollapsible } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, @@ -21,6 +20,12 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +// 좌측 패널(도면 목록·설계 정보)은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +import { + buildDesignInfoPanel, + buildDrawingSidePanel, + isCrossSheet, +} from "./B07_DesignDetail_UI_Panels"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -34,7 +39,6 @@ import { fetchDesignDrawingList, invalidateDesignDrawing, type CadDrawing, - type CrossDesignInfo, type DesignDrawingItem, type DesignDrawingResponse, type QuantityTable, @@ -83,227 +87,8 @@ const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; -/** - * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). - * - * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 - * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. - */ -const DRAWING_GROUPS: readonly { - label: string; - kind?: DesignDrawingItem["kind"]; - blankId?: string; -}[] = [ - { label: "표지", kind: "cover" }, - { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, - { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, - { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, - { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, - { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도", blankId: "blank_cross_standard" }, - { label: "횡단면도", kind: "cross" }, - { label: "토적도(유토곡선)", kind: "mass_haul" }, - { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "표준도", blankId: "blank_standard" }, - { label: "용지도", blankId: "blank_landuse" }, -]; - -/** B06 확정 산출물 기반 도면 목록 패널. */ -function buildDrawingSidePanel( - drawings: DesignDrawingItem[], - onSelect: (drawing: DesignDrawingItem) => void, - errorMessage?: string, -): HTMLDivElement { - const panel = document.createElement("div"); - panel.className = "b07-drawing-list"; - const heading = document.createElement("div"); - heading.className = "b07-drawing-list__heading"; - const title = document.createElement("strong"); - title.textContent = "설계 도면"; - const count = document.createElement("span"); - count.textContent = `${drawings.length}건`; - heading.append(title, count); - panel.append(heading); - - if (errorMessage || drawings.length === 0) { - const empty = document.createElement("p"); - empty.className = "b07-drawing-list__empty"; - empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; - panel.append(empty); - return panel; - } - - const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { - const button = document.createElement("button"); - button.type = "button"; - button.className = "b07-drawing-button"; - button.dataset.drawingId = drawing.id; - button.dataset.confirmed = String(drawing.confirmed); - const name = document.createElement("span"); - name.className = "b07-drawing-button__name"; - name.textContent = label; - button.append(name); - button.addEventListener("click", () => onSelect(drawing)); - return button; - }; - - for (const group of DRAWING_GROUPS) { - const items = group.kind - ? drawings.filter((item) => item.kind === group.kind) - : drawings.filter((item) => item.id === group.blankId); - // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. - if (items.length <= 1) { - const [drawing] = items; - // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. - if (!drawing) { - const placeholder = document.createElement("button"); - placeholder.type = "button"; - placeholder.className = "b07-drawing-button"; - placeholder.disabled = true; - placeholder.title = "준비 중"; - placeholder.dataset.pending = "true"; - const name = document.createElement("span"); - name.className = "b07-drawing-button__name"; - name.textContent = group.label; - placeholder.append(name); - panel.append(placeholder); - continue; - } - const button = drawingButton(drawing, group.label); - // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. - button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; - panel.append(button); - continue; - } - const section = document.createElement("section"); - // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). - section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; - if (group.kind) section.dataset.kind = group.kind; - const sectionTitle = document.createElement("h3"); - sectionTitle.className = "ui-collapsible__title"; - sectionTitle.textContent = `${group.label} ${items.length}`; - section.append(sectionTitle); - for (const drawing of items) { - // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. - section.append(drawingButton(drawing, drawing.label)); - } - panel.append(section); - } - attachCollapsible(panel); - return panel; -} - -const GROUND_TYPE_LABEL: Record = { - soil: "B06_Design_Ground_Soil", - ripping_rock: "B06_Design_Ground_Ripping", - blasting_rock: "B06_Design_Ground_Blasting", -}; - -/** 단면유형에서 절토측 표기를 유도한다. */ -function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { - if (mode === "left_cut") return L("B06_Design_Ditch_Left"); - if (mode === "right_cut") return L("B06_Design_Ditch_Right"); - if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); - return L("B06_Design_Mode_BothFill"); -} - -/** 장(여러 측점을 담은 횡단 도면)인가 — id 가 `cross_s…` 면 장이다. */ -function isCrossSheet(drawing: DesignDrawingItem): boolean { - return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); -} - -/** 측구 규격 표시 문자열 (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.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`; -} - -function infoRow(label: string, value: string): HTMLElement { - const row = document.createElement("div"); - row.className = "b07-info__row"; - const key = document.createElement("span"); - key.className = "b07-info__key"; - key.textContent = label; - const val = document.createElement("span"); - val.className = "b07-info__val"; - val.textContent = value; - row.append(key, val); - return row; -} - -/** - * 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). - * - * **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지 - * 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리). - * 장이면 제목을 「장」으로 바꾸고 어디서 봐야 하는지 한 줄로 알린다. - */ -function buildDesignInfoPanel( - title: string, - design: CrossDesignInfo | null, - scope: "station" | "sheet" = "station", -): HTMLElement { - const panel = document.createElement("div"); - panel.className = "b07-info"; - 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"); - 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"); - 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"); - panel.append(empty); - return panel; - } - - const ground = document.createElement("section"); - ground.className = "b07-info__block"; - const groundTitle = document.createElement("h4"); - groundTitle.textContent = L("B07_Info_Ground_Title"); - ground.append( - groundTitle, - infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), - 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"), - ), - ); - - const plan = document.createElement("section"); - plan.className = "b07-info__block"; - const planTitle = document.createElement("h4"); - 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_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`), - infoRow(L("B07_Info_Ditch"), ditchLabel(design)), - infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), - infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), - ); - - panel.append(ground, plan); - return panel; -} - /* ----------------------------------------------------------------------------- - * 페이지 진입점 + * 화면 조립 * -------------------------------------------------------------------------- */ export async function renderB07DesignDetail(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts new file mode 100644 index 00000000..bf045511 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -0,0 +1,234 @@ +/* ============================================================================= + * B07_DesignDetail_UI_Panels.ts + * 상세 설계 화면의 좌측 패널 — 도면 목록 12분류, 측점 설계 정보 카드. + * + * 화면 조립(`B07_DesignDetail_UI_Page.ts`)이 700줄을 넘어 떼어냈다(2026-09-04). + * DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다. + * ========================================================================== */ + +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"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** + * 도면 구성 12분류 (2026-08-29 사용자 확정 순서). + * + * 아직 내용을 만들지 않은 도면은 `blankId`로 서버의 빈 도각 도면에 물린다 + * (2026-09-01 사용자 지시) — 눌리지 않는 회색 버튼으로 두면 고장난 것처럼 보인다. + */ +export const DRAWING_GROUPS: readonly { + label: string; + kind?: DesignDrawingItem["kind"]; + blankId?: string; +}[] = [ + { label: "표지", kind: "cover" }, + { label: "계획평면도(지형)", blankId: "blank_plan_terrain" }, + { label: "계획평면도(노선배치도)", blankId: "blank_plan_route" }, + { label: "계획평면도(배치도)", blankId: "blank_plan_layout" }, + { label: "계획평면도(라이다)", blankId: "blank_plan_lidar" }, + { label: "종단면도", kind: "longitudinal" }, + { label: "표준 횡단면도", blankId: "blank_cross_standard" }, + { label: "횡단면도", kind: "cross" }, + { label: "토적도(유토곡선)", kind: "mass_haul" }, + { label: "유역도(배수 유역도)", kind: "watershed" }, + { label: "표준도", blankId: "blank_standard" }, + { label: "용지도", blankId: "blank_landuse" }, +]; + +/** B06 확정 산출물 기반 도면 목록 패널. */ +export function buildDrawingSidePanel( + drawings: DesignDrawingItem[], + onSelect: (drawing: DesignDrawingItem) => void, + errorMessage?: string, +): HTMLDivElement { + const panel = document.createElement("div"); + panel.className = "b07-drawing-list"; + const heading = document.createElement("div"); + heading.className = "b07-drawing-list__heading"; + const title = document.createElement("strong"); + title.textContent = "설계 도면"; + const count = document.createElement("span"); + count.textContent = `${drawings.length}건`; + heading.append(title, count); + panel.append(heading); + + if (errorMessage || drawings.length === 0) { + const empty = document.createElement("p"); + empty.className = "b07-drawing-list__empty"; + empty.textContent = errorMessage ?? "확정된 종·횡단 도면이 없습니다."; + panel.append(empty); + return panel; + } + + const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "b07-drawing-button"; + button.dataset.drawingId = drawing.id; + button.dataset.confirmed = String(drawing.confirmed); + const name = document.createElement("span"); + name.className = "b07-drawing-button__name"; + name.textContent = label; + button.append(name); + button.addEventListener("click", () => onSelect(drawing)); + return button; + }; + + for (const group of DRAWING_GROUPS) { + const items = group.kind + ? drawings.filter((item) => item.kind === group.kind) + : drawings.filter((item) => item.id === group.blankId); + // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. + if (items.length <= 1) { + const [drawing] = items; + // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. + if (!drawing) { + const placeholder = document.createElement("button"); + placeholder.type = "button"; + placeholder.className = "b07-drawing-button"; + placeholder.disabled = true; + placeholder.title = "준비 중"; + placeholder.dataset.pending = "true"; + const name = document.createElement("span"); + name.className = "b07-drawing-button__name"; + name.textContent = group.label; + placeholder.append(name); + panel.append(placeholder); + continue; + } + const button = drawingButton(drawing, group.label); + // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. + button.dataset.pending = String(drawing.kind === "blank"); + if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; + panel.append(button); + continue; + } + const section = document.createElement("section"); + // ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시). + section.className = "b07-drawing-group ui-collapsible ui-sidebar-section"; + if (group.kind) section.dataset.kind = group.kind; + const sectionTitle = document.createElement("h3"); + sectionTitle.className = "ui-collapsible__title"; + sectionTitle.textContent = `${group.label} ${items.length}`; + section.append(sectionTitle); + for (const drawing of items) { + // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. + section.append(drawingButton(drawing, drawing.label)); + } + panel.append(section); + } + attachCollapsible(panel); + return panel; +} + +const GROUND_TYPE_LABEL: Record = { + soil: "B06_Design_Ground_Soil", + ripping_rock: "B06_Design_Ground_Ripping", + blasting_rock: "B06_Design_Ground_Blasting", +}; + +/** 단면유형에서 절토측 표기를 유도한다. */ +function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string { + if (mode === "left_cut") return L("B06_Design_Ditch_Left"); + if (mode === "right_cut") return L("B06_Design_Ditch_Right"); + if (mode === "both_cut") return L("B06_Design_Mode_BothCut"); + return L("B06_Design_Mode_BothFill"); +} + +/** 장(여러 측점을 담은 횡단 도면)인가 — id 가 `cross_s…` 면 장이다. */ +export function isCrossSheet(drawing: DesignDrawingItem): boolean { + return drawing.kind === "cross" && drawing.id.startsWith("cross_s"); +} + +/** 측구 규격 표시 문자열 (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.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`; +} + +function infoRow(label: string, value: string): HTMLElement { + const row = document.createElement("div"); + row.className = "b07-info__row"; + const key = document.createElement("span"); + key.className = "b07-info__key"; + key.textContent = label; + const val = document.createElement("span"); + val.className = "b07-info__val"; + val.textContent = value; + row.append(key, val); + return row; +} + +/** + * 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). + * + * **장(여러 측점을 담은 도면)에는 측점 단위 값이 없다** — 서버가 `design` 을 넘기지 + * 않는데도 제목만 「측점 …」으로 달려 어느 측점 값인지 오해됐다(2026-09-03 정리). + * 장이면 제목을 「장」으로 바꾸고 어디서 봐야 하는지 한 줄로 알린다. + */ +export function buildDesignInfoPanel( + title: string, + design: CrossDesignInfo | null, + scope: "station" | "sheet" = "station", +): HTMLElement { + const panel = document.createElement("div"); + panel.className = "b07-info"; + 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"); + 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"); + 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"); + panel.append(empty); + return panel; + } + + const ground = document.createElement("section"); + ground.className = "b07-info__block"; + const groundTitle = document.createElement("h4"); + groundTitle.textContent = L("B07_Info_Ground_Title"); + ground.append( + groundTitle, + infoRow(L("B07_Info_GroundType"), L(GROUND_TYPE_LABEL[design.ground_type])), + 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"), + ), + ); + + const plan = document.createElement("section"); + plan.className = "b07-info__block"; + const planTitle = document.createElement("h4"); + 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_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`), + infoRow(L("B07_Info_Ditch"), ditchLabel(design)), + infoRow(L("B07_Info_CutArea"), `${design.cut_area_m2.toFixed(2)}㎡`), + infoRow(L("B07_Info_FillArea"), `${design.fill_area_m2.toFixed(2)}㎡`), + ); + + panel.append(ground, plan); + return panel; +} -- 2.54.0 From 7500f37acd1408e9f8b959a7bb7d1ccf77fda5ca Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:37:46 +0900 Subject: [PATCH 084/441] =?UTF-8?q?refactor(B05):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=20=ED=8C=A8=EB=84=90=20700?= =?UTF-8?q?=EC=A4=84=20=EB=B6=84=EB=A6=AC=20(=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EB=B6=88=EB=B3=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05_Profile_UI_Structures_Panel.ts 1024 -> 669줄. 조각 셋 모두 700줄 이하. - _Structures_Panel_Types.ts(94줄) — GROUP_LABELS · PipeFacilityItem · StructuresSection · StructuresCallbacks · 기본 구간 길이 상수. 패널이 다시 export 해 옛 임포트 경로 유지. - _Structures_Panel_Commit.ts(312줄) — 폼 불러오기(구조물·계곡 통과 시설)와 [추가]/[수정] 저장, 목록 알림. 검증 순서·분기·수치는 그대로이고 패널 클로저 값만 ctx 로 받음. - _Structures_Panel_Events.ts(146줄) — 드롭다운·버튼·측점 입력 배선. 리스너 본문 그대로. StructuresEventContext 가 StructuresCommitContext 를 상속해 패널은 컨텍스트 하나만 만듦. 가변 상태 4개(editingId · selectedPipeChainage · tempPipeChainage · positionConfirmed)의 주인은 그대로 패널이고, 읽기 함수 + set* 로만 넘김. 검증: 분리 전 export 4개가 진입 파일에서 그대로 보임, tsc --noEmit 통과, prettier 적용, tmp/tests 183 passed. 공용 브라우저(5174)에서 실제 조작으로 저장 경로까지 확인 — C 사면안정/옹벽을 기준측점 5+0 에 [추가] -> 목록 반영, [저장] -> 서버 정본 2건(revision 0->1), 새로고침 후 목록에서 골라 [삭제] -> [저장] -> 서버 0건(revision 4). 시험 프로젝트는 원래 상태(구조물 0건)로 되돌려 둠. 참고(분리와 무관한 기존 동작): 방금 [추가]해 아직 저장 전인 항목은 structure_id 가 없어 [삭제]가 듣지 않음. 저장 뒤에는 정상 삭제됨. 분리 전 코드와 같은 분기라 이번 변경과 무관. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_Profile_UI_Structures_Panel.ts | 527 +++--------------- .../B05_Profile_UI_Structures_Panel_Commit.ts | 312 +++++++++++ .../B05_Profile_UI_Structures_Panel_Events.ts | 146 +++++ .../B05_Profile_UI_Structures_Panel_Types.ts | 94 ++++ 4 files changed, 638 insertions(+), 441 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 892cde77..fabeafe0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -21,98 +21,33 @@ import { type StructurePlacement, type StructureType, } from "./B05_Profile_Api_Structures"; -import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - type FacilityAttributes, - type FacilityOptionsForm, -} from "./B05_Profile_UI_Drainage_Facility"; +import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; +import { + bindStructuresEvents, + type StructuresEventContext, +} from "./B05_Profile_UI_Structures_Panel_Events"; +import { + commit as commitInto, + emit as emitFrom, + loadForm as loadFormInto, + loadPipeForm as loadPipeFormInto, +} from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { formatStation } from "./B05_Profile_Util_Station"; -/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. - * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ -export const GROUP_LABELS: Record = { - A: "A 횡단배수", - B: "B 종단배수", - C: "C 사면안정", - D: "D 계류·사방", - E: "E 안전·부대·용지", - F: "F 생태·녹화", - G: "G 노면공", - 호환: "기타", -}; - -/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ -const DEFAULT_INTERVAL_LENGTH_M = 15; - -/** 계곡 통과 시설 1건 — 관 지점 정본에서 온 병합 표시·폼 편집용 항목. */ -export interface PipeFacilityItem { - chainage_m: number; - facility: PipeFacility; - start_m?: number; - end_m?: number; - /** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */ - source?: PipeSource; - /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */ - design_flow_m3s?: number | null; - /** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */ - options?: Record; -} - -export interface StructuresSection { - root: HTMLElement; - /** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구 - * 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */ - facility: FacilityOptionsForm; - /** 배치된 구조물 목록(
- 그림 + 그림 (칸에 비율 그대로 들어감) {IMAGE_TOKENS.map((token) => ( ))}
+ {picked && ( +
+ 고른 자리표 칸 크기 (mm) + + +
+ )} ); }; diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index ca2741b8..aa219991 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -33,6 +33,22 @@ export class ImageEntity implements Entity { * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. */ private sourceData: string | null = null; + /** 저장값(그림 주소 또는 자리표 토큰). */ + public getSourceData(): string | null { + return this.sourceData; + } + + /** 자리표인가 — `{{회사로고}}` 처럼 토큰을 들고 있는 그림. */ + public isPlaceholder(): boolean { + return (this.sourceData ?? '').includes('{{'); + } + + /** 도각 편집에서만 쓰는 보여 주기용 그림. 저장값(sourceData)은 토큰 그대로 둔다. */ + public setPreviewImage(dataUrl: string): void { + const image = new Image(); + image.src = dataUrl; + this.imageElement = image; + } constructor( layerId: string, @@ -72,7 +88,7 @@ export class ImageEntity implements Entity { // 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서 // 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다 // (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다. - const placeholder = (this.sourceData ?? '').includes('{{'); + const placeholder = this.isPlaceholder() && !this.imageElement.src; // 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰 // 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02). if (highlighted || selected || placeholder) { @@ -82,6 +98,7 @@ export class ImageEntity implements Entity { } if (placeholder) { + // 아직 보여 줄 그림이 없으면 이름표만 남긴다. drawController.drawText(this.sourceData ?? '', this.polygon.box.center, { ...DEFAULT_TEXT_OPTIONS, textAlign: 'center', @@ -91,20 +108,38 @@ export class ImageEntity implements Entity { return; // 그림이 없으니 그릴 것도 없다 } - const width = this.polygon.box.width; - const height = this.polygon.box.height; + // 칸 안에 **비율을 지켜** 넣는다 (2026-09-06 사용자 지시) — 칸을 늘렸다고 그림이 + // 늘어나면 로고·서명이 찌그러진다. 남는 자리는 비운다(가운데 맞춤). + const boxWidth = this.polygon.box.width; + const boxHeight = this.polygon.box.height; + const naturalWidth = this.imageElement.naturalWidth || boxWidth; + const naturalHeight = this.imageElement.naturalHeight || boxHeight; + const fit = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight); + const width = naturalWidth * fit; + const height = naturalHeight * fit; // Draw image drawController.drawImage( this.imageElement, - this.polygon.box.xmin, - this.polygon.box.ymin, + this.polygon.box.xmin + (boxWidth - width) / 2, + this.polygon.box.ymin + (boxHeight - height) / 2, width, height, this.angle ); } + /** 자리표 칸 크기(mm)를 바꾼다. 가운데는 그대로 두고 네 귀만 다시 잡는다. */ + public setBoxSize(width: number, height: number): void { + const center = this.polygon.box.center; + const halfWidth = Math.max(width, 1) / 2; + const halfHeight = Math.max(height, 1) / 2; + this.polygon = twoPointBoxToPolygon( + new Point(center.x - halfWidth, center.y - halfHeight), + new Point(center.x + halfWidth, center.y + halfHeight) + ); + } + public move(x: number, y: number) { this.polygon = this.polygon.translate(new Vector(x, y)); } diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 2a033abe..a0e06f2c 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -20,6 +20,13 @@ export interface TextOptions { /** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */ bold?: boolean; italic?: boolean; + /** + * 도각 자리표의 칸 크기(mm). 있으면 `basePoint` 가 **칸의 한가운데**이고 글자는 + * 가로·세로 가운데 맞춤으로 그려진다 (2026-09-06 사용자 지시). 없으면 예전처럼 + * 글자 하나로만 산다. + */ + boxWidth?: number; + boxHeight?: number; } export class TextEntity implements Entity { @@ -32,6 +39,12 @@ export class TextEntity implements Entity { public opacity?: number; /** GROUP으로 묶인 객체가 공유하는 식별자 */ public groupId?: string; + /** + * 도각 편집에서만 쓰는 **보여 주기용 값** (2026-09-06 사용자 지시). 자리표 + * `{{공사명}}` 대신 실제 공사명을 그려 사용자가 어디에 무엇이 들어가는지 알게 한다. + * 저장값(`label`)은 토큰 그대로다 — 값이 아니라 연결을 저장한다. + */ + public previewLabel: string | null = null; private readonly options: TextOptions; constructor( @@ -55,7 +68,7 @@ export class TextEntity implements Entity { const highlighted = parentHighlighted ?? isEntityHighlighted(this); const selected = parentSelected ?? isEntitySelected(this); drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash); - drawController.drawText(this.label, this.basePoint, this.options); + drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options); // 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이 // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 // 없어 이 선이 실리지 않는다. @@ -114,6 +127,16 @@ export class TextEntity implements Entity { } public getBoundingBox(): Box { + const { boxWidth, boxHeight } = this.options; + if (boxWidth && boxHeight) { + // 자리표는 칸이 곧 경계다 — basePoint 가 칸 한가운데다. + return new Box( + this.basePoint.x - boxWidth / 2, + this.basePoint.y - boxHeight / 2, + this.basePoint.x + boxWidth / 2, + this.basePoint.y + boxHeight / 2 + ); + } // TODO find better way of determining the text bounding box return new Box( this.basePoint.x, @@ -123,6 +146,12 @@ export class TextEntity implements Entity { ); } + /** 자리표 칸 크기(mm)를 바꾼다. 글자 크기는 그대로 둔다. */ + public setBoxSize(width: number, height: number): void { + this.options.boxWidth = Math.max(width, 1); + this.options.boxHeight = Math.max(height, 1); + } + public getTextOptions(): TextOptions { return this.options; } @@ -196,6 +225,8 @@ export class TextEntity implements Entity { fontFamily: this.options.fontFamily, bold: this.options.bold, italic: this.options.italic, + boxWidth: this.options.boxWidth, + boxHeight: this.options.boxHeight, }, }, }; @@ -220,6 +251,8 @@ export class TextEntity implements Entity { fontFamily: jsonEntity.shapeData.options.fontFamily, bold: jsonEntity.shapeData.options.bold, italic: jsonEntity.shapeData.options.italic, + boxWidth: jsonEntity.shapeData.options.boxWidth, + boxHeight: jsonEntity.shapeData.options.boxHeight, } ); textEntity.id = jsonEntity.id; @@ -241,5 +274,8 @@ export interface TextJsonData { fontFamily: string; bold?: boolean; italic?: boolean; + /** 도각 자리표 칸 크기(mm) — basePoint 가 칸 한가운데다. */ + boxWidth?: number; + boxHeight?: number; }; } diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index d9395713..58511053 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,5 +1,6 @@ import { Point } from '@flatten-js/core'; import { type DesignMeta, HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; import { TextEntity } from '../entities/TextEntity.ts'; import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; @@ -9,6 +10,7 @@ import { getCanvas, getDesignMeta, getEntities, + getFrameFields, getLayers, getScreenCanvasDrawController, isDrawingDirty, @@ -40,6 +42,8 @@ interface DrawingLoadMessage { meta?: DesignMeta | null; /** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */ frameEdit?: boolean; + /** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */ + frameFields?: Record; } interface DrawingSaveRequestMessage { @@ -125,6 +129,27 @@ function registerTextDoubleClickEdit() { }); } +/** + * 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다 + * (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적. + */ +function applyFramePreview(): void { + const fields = getFrameFields(); + if (Object.keys(fields).length === 0) return; + const token = /^\{\{(.+)\}\}$/; + for (const entity of getEntities()) { + if (entity instanceof TextEntity) { + const match = token.exec(entity.getLabel().trim()); + const value = match ? fields[match[1]] : undefined; + entity.previewLabel = value ?? null; + } else if (entity instanceof ImageEntity && entity.isPlaceholder()) { + const match = token.exec((entity.getSourceData() ?? '').trim()); + const value = match ? fields[match[1]] : undefined; + if (value) entity.setPreviewImage(value); + } + } +} + /** * B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다. * DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다. @@ -160,7 +185,8 @@ export function registerAisloDrawingBridge() { resetUndoBaseline(); // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); - setFrameEditMode(event.data.frameEdit === true); + setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); + if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: // 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중 diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index 69510310..1fcf3217 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -188,6 +188,8 @@ let snapTrackingEnabled = true; let designMeta: DesignMeta | null = null; /** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */ let frameEditMode = false; +/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ +let frameFields: Record = {}; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -259,6 +261,7 @@ export const getGridEnabled = () => gridEnabled; export const getSnapTrackingEnabled = () => snapTrackingEnabled; export const getDesignMeta = (): DesignMeta | null => designMeta; export const isFrameEditMode = (): boolean => frameEditMode; +export const getFrameFields = (): Record => frameFields; export const isDrawingDirty = () => drawingDirty; /** * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 @@ -490,8 +493,9 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { triggerReactUpdate(StateVariable.designMeta); }; /** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ -export const setFrameEditMode = (enabled: boolean) => { +export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { frameEditMode = enabled; + frameFields = enabled ? fields : {}; notifyWindow(HtmlEvent.UPDATE_STATE); }; // 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정). -- 2.54.0 From df2dedcbc4e626134bc376a3ab13f3b32bce67e8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 15:28:20 +0900 Subject: [PATCH 157/441] =?UTF-8?q?perf:=20=ED=99=94=EB=A9=B4=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=A4=91=EB=B3=B5=20=ED=98=B8=EC=B6=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=EC=84=B8=EC=85=98=C2=B7=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=20=EC=83=81=ED=83=9C=C2=B7=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(B05 진입): auth/session 4회, workflow-state 2회, 구조물 이관 POST 가 진입할 때마다 나갔다. - auth/session: 라우터 가드·상단 바·워크플로 확인이 각각 부르던 것을 5초 캐시로 묶음. 로그인·로그아웃에서 즉시 버린다. - workflow-state: 공용 창구 하나로 모으고 같은 방식으로 캐시. 상단 오버레이가 따로 부르던 경로를 그 창구로 돌림. 단계 이동에서 캐시를 버린다. - 구 구조물 측점 이관 POST: 탭 수명 동안 노선마다 한 번만. 실측 결과: B05 첫 진입 20 → 18건(핵심 API 10건), B06→B05 이동 7건. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_workflow_nav.ts | 40 ++++++++++++++++++++++----- A06_Login/A06_Login_Api_Fetch.ts | 43 +++++++++++++++++++++++------- B05_Profile/B05_Profile_UI_Page.ts | 9 ++++++- ui_template/ui_template_overlay.ts | 6 +++-- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index bbd26970..52a2b613 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -30,15 +30,39 @@ export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [ ROUTES.B09_ESTIMATION, ]; +/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 — + 실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다. + 단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */ +const WORKFLOW_CACHE_MS = 5000; +let workflowCache: { at: number; projectId: string; value: Promise } | null = null; + +/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */ +export function clearWorkflowStateCache(): void { + workflowCache = null; +} + export async function fetchWorkflowState(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { - credentials: "include", - }); - if (!response.ok) { - throw new Error(`Workflow state request failed: ${response.status}`); + const now = Date.now(); + if ( + workflowCache && + workflowCache.projectId === projectId && + now - workflowCache.at < WORKFLOW_CACHE_MS + ) { + return workflowCache.value; } - const data = await response.json(); - return data.workflow_state ?? data; + const value = (async (): Promise => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Workflow state request failed: ${response.status}`); + } + const data = await response.json(); + return data.workflow_state ?? data; + })(); + workflowCache = { at: now, projectId, value }; + value.catch(() => clearWorkflowStateCache()); + return value; } /** @@ -52,6 +76,8 @@ const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, RO export function goToWorkflowStage(projectId: string, route: RoutePath): void { localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. + clearWorkflowStateCache(); if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { navigateTo(route); return; diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index ac768d3b..c2565d99 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -32,25 +32,48 @@ export function requestLogin(email: string, password: string): Promise { + clearSessionCache(); return post("/auth/login/verify", { email, otp_code: otpCode }); } +/* 세션 조회는 한 화면을 여는 동안 라우터 가드·상단 바·워크플로 확인이 각각 부른다 — + 실측 B05 진입에서 `GET /auth/session` 이 네 번 나갔다(2026-09-06). 같은 답을 네 번 + 받을 이유가 없으므로 **짧은 시간 동안 한 번만** 부르고 나눠 쓴다. 로그인·로그아웃 + 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ +const SESSION_CACHE_MS = 5000; +let sessionCache: { at: number; value: Promise } | null = null; + +/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ +export function clearSessionCache(): void { + sessionCache = null; +} + +function sessionOnce(): Promise { + const now = Date.now(); + if (sessionCache && now - sessionCache.at < SESSION_CACHE_MS) return sessionCache.value; + const value = (async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); + if (!response.ok) return null; + const data = (await response.json()) as { status: string; user?: SessionUser }; + return data.user ?? null; + } catch { + return null; + } + })(); + sessionCache = { at: now, value }; + return value; +} + export async function fetchSession(): Promise { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - return response.ok; + return (await sessionOnce()) !== null; } export async function fetchSessionUser(): Promise { - try { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - if (!response.ok) return null; - const data = (await response.json()) as { status: string; user?: SessionUser }; - return data.user ?? null; - } catch { - return null; - } + return sessionOnce(); } export function logout(): Promise { + clearSessionCache(); return post("/auth/logout"); } diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index aeced756..e21df61d 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -72,6 +72,9 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +const migratedRoutes = new Set(); + export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { @@ -538,7 +541,11 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: station.chainage_m, structure: station.structure ?? "", })); - if (legacy.length) { + // 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안 + // 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다. + const migrateKey = `${activeProjectId}:${routeId}`; + if (legacy.length && !migratedRoutes.has(migrateKey)) { + migratedRoutes.add(migrateKey); void migrateLegacyStations(activeProjectId, legacy) .then((result) => { if (result.migrated > 0) { diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 607c6093..70c79471 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -2,7 +2,9 @@ import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { makePanelDraggable } from "./ui_template_overlay_drag"; -import { fetchProjectWorkflowState } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 +// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -124,7 +126,7 @@ function createProjectNameTag(): HTMLElement { tag.className = "ui-workflow-overlay__project"; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return tag; - void fetchProjectWorkflowState(projectId) + void fetchWorkflowState(projectId) .then((state) => { const name = state.project_name ?? ""; tag.textContent = name; -- 2.54.0 From d0d3f2a3114819a600709241b8e4b3cac8da5441 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 15:52:36 +0900 Subject: [PATCH 158/441] =?UTF-8?q?feat(B07):=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=ED=91=9C=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=EC=A1=B0=EC=A0=88=C2=B7=EA=B8=B0=EB=B3=B8=20=EB=8F=84=EA=B0=81?= =?UTF-8?q?=20=EC=B9=B8=20=EC=A0=95=EB=A0=AC=C2=B7=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=EB=AA=85=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸이 있는 글자와 그림에 모서리 그립 추가, 모서리를 끌어 칸 크기를 바꾸는 조작 신설 (다른 도면 요소에도 동일 적용) - 기본 도각(00_template_A1)의 글자 24개에 표제란 칸을 계산해 부여 — 칸 기준 가로·세로 가운데 정렬 - 도각 편집 시 도면명·도면번호도 보던 도면 값으로 미리보기 제공 - 복제 시 미리보기 값 유지 (그립 편집 후 자리표가 토큰으로 되돌아가던 문제) - CAD index.html 을 캐시하지 않도록 처리 — 빌드 후에도 옛 화면이 남던 문제 해소 Co-Authored-By: Claude Opus 5 (1M context) --- .../B07_DesignDetail_UI_FrameEdit.ts | 9 +- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 2 + .../openwebcad/src/entities/ImageEntity.ts | 8 + .../openwebcad/src/entities/TextEntity.ts | 17 +- .../openwebcad/src/helpers/grips.ts | 37 +- .../src/integration/aislo-drawing-bridge.ts | 2 + .../src/integration/box-resize-drag.ts | 119 + main.py | 18 +- .../template_2dDrawing/00_template_A1.json | 2648 +++++++++-------- 9 files changed, 1555 insertions(+), 1305 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 437afe99..a27b4cb1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -42,6 +42,8 @@ interface Options { restoreDrawing: () => void; /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ onSaved: () => void; + /** 지금 보던 도면의 이름·번호 — 자리표 미리보기에 도면명·도면번호로 보여 준다. */ + currentDrawingInfo: () => { label: string; number: string } | null; } export function createFrameTemplateEditor(options: Options): FrameTemplateEditor { @@ -133,7 +135,12 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor async function enter(): Promise { try { const response = await fetchFrameTemplate(options.projectId); - frameFields = response.fields ?? {}; + // 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다. + const info = options.currentDrawingInfo(); + frameFields = { + ...(response.fields ?? {}), + ...(info ? { 도면명: info.label, 도면번호: info.number } : {}), + }; editing = true; button.disabled = true; banner.hidden = false; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 6731f52e..3cb61056 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -453,6 +453,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: () => drawingCache.clear(), + currentDrawingInfo: () => + currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); window.addEventListener("message", (event: MessageEvent) => { diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index aa219991..7606d0ef 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -140,6 +140,14 @@ export class ImageEntity implements Entity { ); } + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.polygon = twoPointBoxToPolygon( + new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)), + new Point(Math.max(a.x, b.x), Math.max(a.y, b.y)) + ); + } + public move(x: number, y: number) { this.polygon = this.polygon.translate(new Vector(x, y)); } diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index a0e06f2c..6479e156 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -110,12 +110,16 @@ export class TextEntity implements Entity { } public clone(): TextEntity { - return new TextEntity( + const copy = new TextEntity( getActiveLayerId(), this.label, this.basePoint.clone(), cloneDeep(this.options) ); + // 보여 주기용 값도 함께 옮긴다 — 안 옮기면 그립을 옮긴 순간 자리표가 다시 + // `{{도면명}}` 으로 보인다(2026-09-06 실측). + copy.previewLabel = this.previewLabel; + return copy; } public intersectsWithBox(box: Box): boolean { @@ -152,6 +156,17 @@ export class TextEntity implements Entity { this.options.boxHeight = Math.max(height, 1); } + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.setBoxSize(Math.abs(b.x - a.x), Math.abs(b.y - a.y)); + this.basePoint = new Point((a.x + b.x) / 2, (a.y + b.y) / 2); + } + + /** 자리표 칸이 있는가 — 칸이 있으면 basePoint 가 칸 한가운데다. */ + public hasBox(): boolean { + return Boolean(this.options.boxWidth && this.options.boxHeight); + } + public getTextOptions(): TextOptions { return this.options; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/grips.ts b/B07_DesignDetail/openwebcad/src/helpers/grips.ts index ccfb3dda..3f00c182 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/grips.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/grips.ts @@ -2,11 +2,13 @@ * 그립 — 선택한 객체에 붙는 편집점. 집어서 다음 클릭 위치로 옮긴다. * 형상 필드가 전부 private이라 좌표를 고칠 때는 공개 생성자로 같은 객체를 다시 만들어 * 배열에서 바꿔 끼운다(id는 그대로 둬서 선택·그룹이 유지된다). - * ponytail: 호·해치·이미지·치수는 그립을 만들지 않는다 — 각각 각도·경계·비율·연관 규칙이 - * 따로 있어 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * ponytail: 호·해치·치수는 그립을 만들지 않는다 — 각각 각도·경계·연관 규칙이 따로 있어 + * 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * 그림과 「칸이 있는 글자」는 네 모서리를 끌어 **칸 크기**를 바꾼다 (2026-09-06 사용자 지시). */ import { type Circle, Point, type Polygon, type Segment } from '@flatten-js/core'; import { CircleEntity } from '../entities/CircleEntity'; +import { ImageEntity } from '../entities/ImageEntity'; import type { Entity } from '../entities/Entity'; import { LineEntity } from '../entities/LineEntity'; import { PointEntity } from '../entities/PointEntity'; @@ -124,6 +126,20 @@ export function getGrips(entity: Entity): Grip[] { } return grips; } + // 칸이 있는 글자·그림 — 네 모서리로 칸을 늘이고 줄인다. 가운데 그립은 옮기기. + if ( + (entity instanceof TextEntity && entity.hasBox()) || + entity instanceof ImageEntity + ) { + const box = entity.getBoundingBox(); + return [ + { point: new Point(box.xmin, box.ymin), kind: 'vertex', index: 0 }, + { point: new Point(box.xmax, box.ymin), kind: 'vertex', index: 1 }, + { point: new Point(box.xmax, box.ymax), kind: 'vertex', index: 2 }, + { point: new Point(box.xmin, box.ymax), kind: 'vertex', index: 3 }, + { point: new Point(box.center.x, box.center.y), kind: 'base', index: 0 }, + ]; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const point = entity.getFirstPoint(); return point ? [{ point, kind: 'base', index: 0 }] : []; @@ -188,6 +204,23 @@ export function applyGrip(entity: Entity, grip: Grip, target: Point): Entity | n copy.setRowHeight(grip.index, top - target.y); return copy; } + // 칸이 있는 글자·그림 — 모서리를 끌면 마주 보는 모서리를 붙박아 칸을 다시 잡는다. + if ((entity instanceof TextEntity && entity.hasBox()) || entity instanceof ImageEntity) { + const box = entity.getBoundingBox(); + if (grip.kind === 'base') { + return moveCopy(entity, target.x - box.center.x, target.y - box.center.y); + } + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + const opposite = corners[(grip.index + 2) % corners.length]; + const copy = inherit(entity, entity.clone()) as TextEntity | ImageEntity; + copy.setBoxFromCorners(target, opposite); + return copy; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const base = entity.getFirstPoint(); if (!base) return null; diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 58511053..9f6cd23c 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -25,6 +25,7 @@ import { import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; +import { registerBoxResizeDrag } from './box-resize-drag.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -207,6 +208,7 @@ export function registerAisloDrawingBridge() { notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() }); }); registerTextDoubleClickEdit(); + registerBoxResizeDrag(); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts new file mode 100644 index 00000000..f2700dfa --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts @@ -0,0 +1,119 @@ +import { Point } from '@flatten-js/core'; +import { HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; +import { TextEntity } from '../entities/TextEntity.ts'; +import { + getCanvas, + getEntities, + getScreenCanvasDrawController, + getSelectedEntities, + isDrawingReadOnly, + setEntities, +} from '../state.ts'; + +/** + * 칸 모서리를 **끌어서** 크기를 바꾼다 (2026-09-06 사용자 지시). + * + * 대상은 「칸이 있는 글자(도각 자리표)」와 「그림」이다. 캐드 본래의 그립은 집었다 놓는 + * 방식이라 도각 칸을 맞출 때 손이 많이 갔다 — 끌기는 여기서 따로 받는다. + * + * 그리기 도구와 부딪히지 않게 **모서리를 집었을 때만** 이벤트를 가로챈다(그 밖에는 그대로 + * 흘려보낸다). 확정한 도면은 읽기 전용이라 손대지 않는다. + */ + +/** 모서리를 집었다고 볼 화면 거리(px). 그립 크기(8px)보다 조금 넉넉하게 잡는다. */ +const GRAB_PIXELS = 9; + +type Resizable = TextEntity | ImageEntity; + +function resizableSelection(): Resizable | null { + const selected = getSelectedEntities(); + if (selected.length !== 1) return null; + const entity = selected[0]; + if (entity instanceof TextEntity && entity.hasBox()) return entity; + if (entity instanceof ImageEntity) return entity; + return null; +} + +/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */ +function worldAt(event: MouseEvent): Point | null { + const canvas = getCanvas(); + if (!canvas) return null; + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + return getScreenCanvasDrawController().targetToWorld(screenPoint); +} + +function corners(entity: Resizable): Point[] { + const box = entity.getBoundingBox(); + return [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; +} + +let dragging: { entity: Resizable; opposite: Point } | null = null; + +export function registerBoxResizeDrag(): void { + const canvas = getCanvas(); + if (!canvas) return; + + // 캡처 단계에서 먼저 받는다 — 모서리를 집은 경우에만 선택 도구로 넘어가지 않게 막는다. + window.addEventListener( + 'mousedown', + (event: MouseEvent) => { + if (event.button !== 0 || event.target !== canvas || isDrawingReadOnly()) return; + const entity = resizableSelection(); + const world = entity ? worldAt(event) : null; + if (!entity || !world) return; + const scale = getScreenCanvasDrawController().getScreenScale(); + const grabDistance = GRAB_PIXELS / scale; + const points = corners(entity); + let index = -1; + let best = grabDistance; + points.forEach((corner, seq) => { + const distance = corner.distanceTo(world)[0]; + if (distance <= best) { + best = distance; + index = seq; + } + }); + if (index < 0) return; + dragging = { entity, opposite: points[(index + 2) % points.length] }; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); + + window.addEventListener( + 'mousemove', + (event: MouseEvent) => { + if (!dragging) return; + const world = worldAt(event); + if (!world) return; + dragging.entity.setBoxFromCorners(world, dragging.opposite); + // 끄는 동안에는 되돌리기 스택에 쌓지 않는다 — 손을 뗄 때 한 번만 쌓는다. + setEntities([...getEntities()], false); + // 자리표 패널의 칸 크기 숫자도 따라 움직이게 알린다. + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + }, + true + ); + + window.addEventListener( + 'mouseup', + (event: MouseEvent) => { + if (!dragging) return; + dragging = null; + setEntities([...getEntities()], true); + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); +} diff --git a/main.py b/main.py index 7e6c2f9d..f667060e 100644 --- a/main.py +++ b/main.py @@ -340,9 +340,25 @@ logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_D # B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist") + + +class _NoCacheHtmlStatic(StaticFiles): + """`index.html` 만 캐시하지 않는다 (2026-09-06). + + 캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로** + 남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다). + """ + + def file_response(self, *args, **kwargs): # type: ignore[override] + response = super().file_response(*args, **kwargs) + if str(getattr(response, "path", "")).endswith(".html"): + response.headers["Cache-Control"] = "no-store" + return response + + app.mount( "/b07-cad", - StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), + _NoCacheHtmlStatic(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), name="b07-cad", ) logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}") diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index 5ccc8f9c..d0b83d57 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1,1352 +1,1400 @@ { - "format": 6, - "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", - "entities": [ - { - "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면명}}", - "basePoint": { - "x": 728.9614, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면번호}}", - "basePoint": { - "x": 794.15392, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{공사명}}", - "basePoint": { - "x": 172.993229, - "y": 26.184925 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.884559874108106, - "y": 566.9570935802498 - }, - "endPoint": { - "x": 811.8687906157556, - "y": 566.9570935802498 - } - } - }, - { - "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ + "format": 6, + "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", + "entities": [ { - "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 566.957094 + "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면명}}", + "basePoint": { + "x": 728.9113785811373, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 22.0 + } } - } }, { - "id": "e490918e-af37-5fe6-abf9-37919db79b53", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 566.957094 + "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면번호}}", + "basePoint": { + "x": 792.890084790497, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 22.0 + } } - } }, { - "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 16.957051 + "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{공사명}}", + "basePoint": { + "x": 116.86879080885097, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 22.0 + } } - } }, { - "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 16.957051 + "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.884559874108106, + "y": 566.9570935802498 + }, + "endPoint": { + "x": 811.8687906157556, + "y": 566.9570935802498 + } } - } - } - ] - }, - { - "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868790618179396, - "y": 38.95705092559213 - }, - "endPoint": { - "x": 811.8845598717162, - "y": 38.95705092559213 - } - } - }, - { - "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 191.868791, - "y": 46.957051 - } - } }, { - "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 311.868791, - "y": 46.957051 - } - } + "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 566.957094 + } + } + }, + { + "id": "e490918e-af37-5fe6-abf9-37919db79b53", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 566.957094 + } + } + }, + { + "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 16.957051 + } + } + }, + { + "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 16.957051 + } + } + } + ] }, { - "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 431.868791, - "y": 46.957051 + "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868790618179396, + "y": 38.95705092559213 + }, + "endPoint": { + "x": 811.8845598717162, + "y": 38.95705092559213 + } } - } }, { - "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 481.868791, - "y": 46.957051 - } - } + "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 191.868791, + "y": 46.957051 + } + } + }, + { + "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 311.868791, + "y": 46.957051 + } + } + }, + { + "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 431.868791, + "y": 46.957051 + } + } + }, + { + "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 481.868791, + "y": 46.957051 + } + } + }, + { + "id": "b4af8196-f773-5019-acd8-47ec851924e0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 531.868791, + "y": 46.957051 + } + } + }, + { + "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 581.868791, + "y": 46.957051 + } + } + }, + { + "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 581.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 631.868791, + "y": 46.957051 + } + } + }, + { + "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 631.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 681.868791, + "y": 46.957051 + } + } + }, + { + "id": "72edfb24-244a-58a0-b73d-61515cfa2260", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 681.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 771.868791, + "y": 46.957051 + } + } + }, + { + "id": "97fe4eec-8e51-570c-a55f-609112757912", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 771.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 811.88456, + "y": 46.957051 + } + } + } + ] }, { - "id": "b4af8196-f773-5019-acd8-47ec851924e0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 531.868791, - "y": 46.957051 + "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "공 사 명", + "basePoint": { + "x": 116.86879080885097, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 8.0 + } } - } }, { - "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 581.868791, - "y": 46.957051 + "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "시 행 청", + "basePoint": { + "x": 251.8687906175109, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 581.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 631.868791, - "y": 46.957051 + "id": "0294bb9d-5715-590c-8f92-0023891244f1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.86879061770193, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 191.86879061770193, + "y": 16.9570509256849 + } } - } }, { - "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 631.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 681.868791, - "y": 46.957051 + "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.8687906173199, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 311.8687906173199, + "y": 16.9570509256849 + } } - } }, { - "id": "72edfb24-244a-58a0-b73d-61515cfa2260", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 681.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 771.868791, - "y": 46.957051 + "id": "e545c507-25b7-5cc4-a203-22cb016cb568", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 42.9570509255885 + } } - } }, { - "id": "97fe4eec-8e51-570c-a55f-609112757912", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 771.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 811.88456, - "y": 46.957051 + "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "축 척", + "basePoint": { + "x": 456.8687906168583, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "공 사 명", - "basePoint": { - "x": 63.850772, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "시 행 청", - "basePoint": { - "x": 206.931974, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0294bb9d-5715-590c-8f92-0023891244f1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.86879061770193, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 191.86879061770193, - "y": 16.9570509256849 - } - } - }, - { - "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.8687906173199, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 311.8687906173199, - "y": 16.9570509256849 - } - } - }, - { - "id": "e545c507-25b7-5cc4-a203-22cb016cb568", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 42.9570509255885 - } - } - }, - { - "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "축 척", - "basePoint": { - "x": 440.340788, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "용 역 회 사", - "basePoint": { - "x": 328.367088, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.86879061693793, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 431.86879061693793, - "y": 16.9570509256849 - } - } - }, - { - "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계 일 자", - "basePoint": { - "x": 485.229485, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.8687906167787, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 481.8687906167787, - "y": 16.9570509256849 - } - } - }, - { - "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 531.8687906166197, - "y": 16.9570509256849 - } - } - }, - { - "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 책 임 자", - "basePoint": { - "x": 541.919243, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 참 여 자", - "basePoint": { - "x": 576.555284, - "y": 43.614023 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 633.9113785814397, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 633.9113785814397, - "y": 16.9570509256849 - } - } - }, - { - "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 583.9113785815989, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 583.9113785815989, - "y": 16.9570509256849 - } - } - }, - { - "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "분 야 별 책 임 자", - "basePoint": { - "x": 592.856439, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "192fbaba-663c-5621-b4be-8fd026441fc6", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 683.9113785812806, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 16.9570509256849 - } - } - }, - { - "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계", - "basePoint": { - "x": 644.130922, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 명", - "basePoint": { - "x": 696.657727, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 773.9113785809941, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 773.9113785809941, - "y": 16.9570509256849 - } - } - }, - { - "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 번 호", - "basePoint": { - "x": 777.837719, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "405c6bca-5474-5c97-ba3b-f050bb937028", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{분야별책임자}}", - "basePoint": { - "x": 602.801356, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{과업책임자}}", - "basePoint": { - "x": 551.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계자}}", - "basePoint": { - "x": 651.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계일자}}", - "basePoint": { - "x": 507.61607, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{용역회사}}", - "basePoint": { - "x": 380.474721, - "y": 28.814653 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "89422706-3f58-52af-8730-5d535e4b1d72", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": -5.042928 - } - } }, { - "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": 588.957072 + "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "용 역 회 사", + "basePoint": { + "x": 371.8687906171289, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": 588.957072 + "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.86879061693793, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 431.86879061693793, + "y": 16.9570509256849 + } } - } }, { - "id": "4e989434-b862-5526-b497-1e4e77f999a1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": -5.042928 + "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계 일 자", + "basePoint": { + "x": 506.8687906166992, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{시행청}}", - "basePoint": { - "x": 263.627137, - "y": 28.814653 }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A1 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 31.289636 + { + "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.8687906167787, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 481.8687906167787, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A3 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 25.208603 + { + "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 531.8687906166197, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A1}}", - "basePoint": { - "x": 462.443604, - "y": 29.323307 + { + "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 책 임 자", + "basePoint": { + "x": 557.8900845991093, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A3}}", - "basePoint": { - "x": 462.443604, - "y": 23.242274 + { + "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 참 여 자", + "basePoint": { + "x": 607.8900845989501, + "y": 44.95705096279425 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 152.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" + { + "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 633.9113785814397, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 633.9113785814397, + "y": 16.9570509256849 + } + } + }, + { + "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 583.9113785815989, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 583.9113785815989, + "y": 16.9570509256849 + } + } + }, + { + "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "분 야 별 책 임 자", + "basePoint": { + "x": 608.9113785815193, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "192fbaba-663c-5621-b4be-8fd026441fc6", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 683.9113785812806, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 16.9570509256849 + } + } + }, + { + "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계", + "basePoint": { + "x": 658.9113785813602, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 명", + "basePoint": { + "x": 728.9113785811373, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 8.0 + } + } + }, + { + "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 773.9113785809941, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 773.9113785809941, + "y": 16.9570509256849 + } + } + }, + { + "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 번 호", + "basePoint": { + "x": 792.890084790497, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 8.0 + } + } + }, + { + "id": "405c6bca-5474-5c97-ba3b-f050bb937028", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{분야별책임자}}", + "basePoint": { + "x": 608.9113785815193, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{과업책임자}}", + "basePoint": { + "x": 557.8900845991093, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 22.0 + } + } + }, + { + "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계자}}", + "basePoint": { + "x": 658.9113785813602, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계일자}}", + "basePoint": { + "x": 506.8687906166992, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{용역회사}}", + "basePoint": { + "x": 371.8687906171289, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "89422706-3f58-52af-8730-5d535e4b1d72", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": -5.042928 + } + } + }, + { + "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": 588.957072 + } + } + }, + { + "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": 588.957072 + } + } + }, + { + "id": "4e989434-b862-5526-b497-1e4e77f999a1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": -5.042928 + } + } + } + ] + }, + { + "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{시행청}}", + "basePoint": { + "x": 251.8687906175109, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A1 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A3 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A1}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A3}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 316.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 36.0 + }, + { + "x": 316.0, + "y": 36.0 + } + ], + "imageData": "{{회사로고}}" + } + }, + { + "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 638.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 25.5 + }, + { + "x": 638.0, + "y": 25.5 + } + ], + "imageData": "{{설계자서명}}" + } + }, + { + "id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 536.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 25.5 + }, + { + "x": 536.89, + "y": 25.5 + } + ], + "imageData": "{{과업책임자서명}}" + } + }, + { + "id": "d404c499-15dc-528f-a3bd-821cefa41961", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 587.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 25.5 + }, + { + "x": 587.91, + "y": 25.5 + } + ], + "imageData": "{{분야별책임자서명}}" + } } - } - }, - { - "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 316.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 36.0 - }, - { - "x": 316.0, - "y": 36.0 - } - ], - "imageData": "{{회사로고}}" - } - }, - { - "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 638.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 25.5 - }, - { - "x": 638.0, - "y": 25.5 - } - ], - "imageData": "{{설계자서명}}" - } - }, - { - "id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 536.89, - "y": 17.5 - }, - { - "x": 578.89, - "y": 17.5 - }, - { - "x": 578.89, - "y": 25.5 - }, - { - "x": 536.89, - "y": 25.5 - } - ], - "imageData": "{{과업책임자서명}}" - } - }, - { - "id": "d404c499-15dc-528f-a3bd-821cefa41961", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 587.91, - "y": 17.5 - }, - { - "x": 629.91, - "y": 17.5 - }, - { - "x": 629.91, - "y": 25.5 - }, - { - "x": 587.91, - "y": 25.5 - } - ], - "imageData": "{{분야별책임자서명}}" - } - } - ], - "layers": [ - { - "id": "-00.기본BOX TEXT", - "name": "-00.기본BOX TEXT", - "isVisible": true, - "isLocked": false - }, - { - "id": "-00.기본BOX", - "name": "-00.기본BOX", - "isVisible": true, - "isLocked": false - } - ] -} + ], + "layers": [ + { + "id": "-00.기본BOX TEXT", + "name": "-00.기본BOX TEXT", + "isVisible": true, + "isLocked": false + }, + { + "id": "-00.기본BOX", + "name": "-00.기본BOX", + "isVisible": true, + "isLocked": false + } + ] +} \ No newline at end of file -- 2.54.0 From 1cf47f39b2a40271fe1477f1b2ccc9fe4860c9e5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 16:11:40 +0900 Subject: [PATCH 159/441] =?UTF-8?q?fix(B06):=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EA=B9=A8=EC=A0=B8=20=EC=9E=88=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포장·세월교 보정 헬퍼를 저장 시점으로 옮기면서 `@router.post(.../save)` 데코레이터와 `save_sections` 사이에 끼워 넣어, 라우트가 헬퍼(None 반환)에 붙어 있었다. 저장이 500(ResponseValidationError)으로 떨어졌다. 헬퍼를 데코레이터 앞으로 옮겨 라우트를 되돌렸다. 검증(용화 신규 프로젝트, 노선 145): 저장 200, 카드에서 고른 지반유형이 DB `cross_sections.data.design.ground_type` 에 반영되고 면적도 브라우저 계산값으로 갱신됨. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Router_Confirm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 1ef95832..ea915f03 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -163,7 +163,6 @@ async def _apply_section_edits( ) -@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) async def _enforce_stored_designs(project_id: UUID, route_id: int) -> None: """저장분의 포장 구간·세월교 노면 하강을 바로잡는다 — 편집분을 얹기 **전에** 돈다. @@ -182,6 +181,7 @@ async def _enforce_stored_designs(project_id: UUID, route_id: int) -> None: ) +@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) async def save_sections( project_id: UUID, route_id: int, -- 2.54.0 From 6fe700494687f435c64979723b0af59b017f089d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 16:17:10 +0900 Subject: [PATCH 160/441] =?UTF-8?q?knowledge(=EA=B5=90=EB=B3=B8):=20?= =?UTF-8?q?=EC=82=B0=EB=A6=BC=EA=B3=BC=EC=9E=84=EC=97=85=EA=B8=B0=EC=88=A0?= =?UTF-8?q?(=EC=9E=84=EB=8F=84)=20=EA=B7=B8=EB=A6=BC=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=AA=85=20=EC=9D=98=EB=AF=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지 번호식 이름(`..._p101_1.png`)을 내용이 드러나는 이름으로 바꿨다. 예: `노선측량_그림5-2-27_중심선과영선의위치비교.png` - 그림 120개 이름 변경(122개 신규 등록), 본문 md 12개의 링크를 새 이름으로. - 추출 파이프라인(`_pipeline/pdf2md.py`)과 QC 리포트 갱신. 확인: 그림 127개 · md 이미지 링크 127개 · 깨진 링크 0. Co-Authored-By: Claude Opus 5 (1M context) --- .../original/_pipeline/data/qc_report.json | 72 ++++++ .../knowledge/original/_pipeline/pdf2md.py | 16 +- .../산림과임업기술(임도)/2. 임도/1. 총론.md | 4 +- .../2. 임도/2. 임도구조.md | 81 ++++-- .../2. 임도/3. 임도계획.md | 52 ++-- .../2. 임도/4. 노선측량.md | 234 +++++++++--------- .../산림과임업기술(임도)/2. 임도/5. 설계.md | 70 +++--- .../산림과임업기술(임도)/2. 임도/6. 시공.md | 98 ++++---- .../3. 훼손지복구/1. 토석채취지복구.md | 20 +- ...량_그림5-2-25_삼각좌표에따른흙의분류도.png | Bin 0 -> 32424 bytes ...측량_그림5-2-26_통일분류법을위한소성도.png | Bin 0 -> 33279 bytes ...측량_그림5-2-27_중심선과영선의위치비교.png | Bin 0 -> 25478 bytes ...측량_그림5-2-28_경사지임도의영선과기면.png | Bin 0 -> 26956 bytes .../노선측량_그림5-2-29_표적판과지지봉.png | Bin 0 -> 8909 bytes .../pic/노선측량_그림5-2-30_토우로프.png | Bin 0 -> 73707 bytes .../노선측량_그림5-2-31_개산물매와수정.png | Bin 0 -> 16962 bytes ...선측량_그림5-2-32_굴곡부의영선과중심선.png | Bin 0 -> 25231 bytes ...량_그림5-2-33_능선부와계곡부의종단물매.png | Bin 0 -> 19552 bytes ...측량_그림5-2-34_방위각법트래버어스측량.png | Bin 0 -> 12890 bytes ...선측량_그림5-2-35_편각법트래버어스측량.png | Bin 0 -> 15945 bytes ...선측량_그림5-2-36_내각법트래버어스측량.png | Bin 0 -> 11666 bytes ...선측량_그림5-2-37_트래버어스측정치조건.png | Bin 0 -> 47017 bytes ...량_그림5-2-38_트래버어스방위각산출_01.png | Bin 0 -> 14894 bytes ...량_그림5-2-38_트래버어스방위각산출_02.png | Bin 0 -> 16514 bytes ...노선측량_그림5-2-39_방위각과방위의관계.png | Bin 0 -> 26292 bytes .../노선측량_그림5-2-40_편각과방위의관계.png | Bin 0 -> 22603 bytes .../노선측량_그림5-2-41_내각과방위의관계.png | Bin 0 -> 21585 bytes .../노선측량_그림5-2-42_위거와경거의관계.png | Bin 0 -> 12239 bytes .../pic/노선측량_그림5-2-43_곡선의종류_01.png | Bin 0 -> 13546 bytes .../pic/노선측량_그림5-2-43_곡선의종류_02.png | Bin 0 -> 23464 bytes .../pic/노선측량_그림5-2-44_곡선부의구조.png | Bin 0 -> 10996 bytes ...노선측량_그림5-2-45_두절선상교점설치법.png | Bin 0 -> 15834 bytes ...선측량_그림5-2-46_편각에의한곡선설치법.png | Bin 0 -> 23538 bytes ...측량_그림5-2-47_시준선장애물곡선설치법.png | Bin 0 -> 19153 bytes ...측량_그림5-2-48_교점접근불가곡선설치법.png | Bin 0 -> 22498 bytes ...측량_그림5-2-49_BC와EC장애물곡선설치법.png | Bin 0 -> 25037 bytes ...노선측량_그림5-2-50_중앙종거곡선설치법.png | Bin 0 -> 21837 bytes ...선측량_그림5-2-51_평행한두직선반향곡선.png | Bin 0 -> 8272 bytes ..._그림5-2-52_평행하지않은두직선반향곡선.png | Bin 0 -> 20159 bytes .../노선측량_그림5-2-53_복심곡선설치법.png | Bin 0 -> 13862 bytes .../노선측량_그림5-2-54_배향곡선의간격.png | Bin 0 -> 8742 bytes .../노선측량_그림5-2-55_배향곡선설치법.png | Bin 0 -> 25212 bytes ...측량_그림5-2-56_종단곡선길이와가시거리.png | Bin 0 -> 24171 bytes ...측량_그림5-2-57_종단곡선길이별가시거리.png | Bin 0 -> 53411 bytes ...노선측량_그림5-2-58_종단곡선측점종횡거.png | Bin 0 -> 20577 bytes .../pic/복구_그림5-3-1_채석적지부위별명칭.png | Bin 0 -> 58819 bytes .../pic/복구_그림5-3-2_주요재료의부호.png | Bin 0 -> 19449 bytes .../pic/복구_그림5-3-3_주요공종의부호_01.png | Bin 0 -> 83119 bytes .../pic/복구_그림5-3-3_주요공종의부호_02.png | Bin 0 -> 158507 bytes .../pic/복구_그림5-3-3_주요공종의부호_03.png | Bin 0 -> 179609 bytes .../복구_그림5-3-4_쇄골재채석장잔벽모형.png | Bin 0 -> 39521 bytes .../pic/복구_그림5-3-5_원석채석장잔벽모형.png | Bin 0 -> 32867 bytes .../복구_그림5-3-6_채석잔벽안정녹화정면도.png | Bin 0 -> 144882 bytes .../복구_그림5-3-7_채석잔벽안정녹화측면도.png | Bin 0 -> 63040 bytes .../pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png | Bin 0 -> 52475 bytes .../pic/산림과임업기술(임도)_p101_1.png | Bin 23018 -> 0 bytes .../pic/산림과임업기술(임도)_p105_1.png | Bin 34280 -> 0 bytes .../pic/산림과임업기술(임도)_p118_1.png | Bin 25770 -> 0 bytes .../pic/산림과임업기술(임도)_p122_1.png | Bin 19702 -> 0 bytes .../pic/산림과임업기술(임도)_p122_2.png | Bin 34293 -> 0 bytes .../pic/산림과임업기술(임도)_p12_1.png | Bin 18359 -> 0 bytes .../pic/산림과임업기술(임도)_p131_1.png | Bin 7856 -> 0 bytes .../pic/산림과임업기술(임도)_p132_1.png | Bin 18882 -> 0 bytes .../pic/산림과임업기술(임도)_p134_1.png | Bin 18712 -> 0 bytes .../pic/산림과임업기술(임도)_p135_1.png | Bin 11567 -> 0 bytes .../pic/산림과임업기술(임도)_p136_1.png | Bin 5350 -> 0 bytes .../pic/산림과임업기술(임도)_p136_2.png | Bin 4133 -> 0 bytes .../pic/산림과임업기술(임도)_p137_1.png | Bin 19753 -> 0 bytes .../pic/산림과임업기술(임도)_p138_1.png | Bin 7438 -> 0 bytes .../pic/산림과임업기술(임도)_p138_2.png | Bin 6348 -> 0 bytes .../pic/산림과임업기술(임도)_p138_3.png | Bin 5486 -> 0 bytes .../pic/산림과임업기술(임도)_p138_4.png | Bin 3540 -> 0 bytes .../pic/산림과임업기술(임도)_p139_1.png | Bin 13173 -> 0 bytes .../pic/산림과임업기술(임도)_p139_2.png | Bin 4261 -> 0 bytes .../pic/산림과임업기술(임도)_p139_3.png | Bin 3807 -> 0 bytes .../pic/산림과임업기술(임도)_p139_4.png | Bin 6787 -> 0 bytes .../pic/산림과임업기술(임도)_p139_5.png | Bin 10037 -> 0 bytes .../pic/산림과임업기술(임도)_p139_6.png | Bin 5002 -> 0 bytes .../pic/산림과임업기술(임도)_p139_7.png | Bin 6325 -> 0 bytes .../pic/산림과임업기술(임도)_p139_8.png | Bin 7293 -> 0 bytes .../pic/산림과임업기술(임도)_p13_1.png | Bin 12150 -> 0 bytes .../pic/산림과임업기술(임도)_p140_1.png | Bin 35774 -> 0 bytes .../pic/산림과임업기술(임도)_p142_1.png | Bin 16735 -> 0 bytes .../pic/산림과임업기술(임도)_p143_1.png | Bin 1322 -> 0 bytes .../pic/산림과임업기술(임도)_p143_2.png | Bin 714 -> 0 bytes .../pic/산림과임업기술(임도)_p143_3.png | Bin 890 -> 0 bytes .../pic/산림과임업기술(임도)_p143_4.png | Bin 984 -> 0 bytes .../pic/산림과임업기술(임도)_p143_5.png | Bin 835 -> 0 bytes .../pic/산림과임업기술(임도)_p145_1.png | Bin 53814 -> 0 bytes .../pic/산림과임업기술(임도)_p149_1.png | Bin 4748 -> 0 bytes .../pic/산림과임업기술(임도)_p149_2.png | Bin 4457 -> 0 bytes .../pic/산림과임업기술(임도)_p150_1.png | Bin 11116 -> 0 bytes .../pic/산림과임업기술(임도)_p151_1.png | Bin 25926 -> 0 bytes .../pic/산림과임업기술(임도)_p151_2.png | Bin 59723 -> 0 bytes .../pic/산림과임업기술(임도)_p151_3.png | Bin 99242 -> 0 bytes .../pic/산림과임업기술(임도)_p152_1.png | Bin 17640 -> 0 bytes .../pic/산림과임업기술(임도)_p153_1.png | Bin 22033 -> 0 bytes .../pic/산림과임업기술(임도)_p154_1.png | Bin 32165 -> 0 bytes .../pic/산림과임업기술(임도)_p159_1.png | Bin 79225 -> 0 bytes .../pic/산림과임업기술(임도)_p164_1.png | Bin 69901 -> 0 bytes .../pic/산림과임업기술(임도)_p164_2.png | Bin 18797 -> 0 bytes .../pic/산림과임업기술(임도)_p165_1.png | Bin 161581 -> 0 bytes .../pic/산림과임업기술(임도)_p166_1.png | Bin 164122 -> 0 bytes .../pic/산림과임업기술(임도)_p168_1.png | Bin 39907 -> 0 bytes .../pic/산림과임업기술(임도)_p168_2.png | Bin 36756 -> 0 bytes .../pic/산림과임업기술(임도)_p172_1.png | Bin 34700 -> 0 bytes .../pic/산림과임업기술(임도)_p172_2.png | Bin 237399 -> 0 bytes .../pic/산림과임업기술(임도)_p176_1.png | Bin 24231 -> 0 bytes .../pic/산림과임업기술(임도)_p18_1.png | Bin 22189 -> 0 bytes .../pic/산림과임업기술(임도)_p18_2.png | Bin 22692 -> 0 bytes .../pic/산림과임업기술(임도)_p24_1.png | Bin 15135 -> 0 bytes .../pic/산림과임업기술(임도)_p25_1.png | Bin 2988 -> 0 bytes .../pic/산림과임업기술(임도)_p2_1.png | Bin 35920 -> 0 bytes .../pic/산림과임업기술(임도)_p30_1.png | Bin 14365 -> 0 bytes .../pic/산림과임업기술(임도)_p30_2.png | Bin 13856 -> 0 bytes .../pic/산림과임업기술(임도)_p30_3.png | Bin 9844 -> 0 bytes .../pic/산림과임업기술(임도)_p31_1.png | Bin 14378 -> 0 bytes .../pic/산림과임업기술(임도)_p31_2.png | Bin 13477 -> 0 bytes .../pic/산림과임업기술(임도)_p31_3.png | Bin 12539 -> 0 bytes .../pic/산림과임업기술(임도)_p31_4.png | Bin 13427 -> 0 bytes .../pic/산림과임업기술(임도)_p32_1.png | Bin 12466 -> 0 bytes .../pic/산림과임업기술(임도)_p32_2.png | Bin 18582 -> 0 bytes .../pic/산림과임업기술(임도)_p32_3.png | Bin 15166 -> 0 bytes .../pic/산림과임업기술(임도)_p33_1.png | Bin 7585 -> 0 bytes .../pic/산림과임업기술(임도)_p35_1.png | Bin 28024 -> 0 bytes .../pic/산림과임업기술(임도)_p35_2.png | Bin 126315 -> 0 bytes .../pic/산림과임업기술(임도)_p42_1.png | Bin 49729 -> 0 bytes .../pic/산림과임업기술(임도)_p43_1.png | Bin 18204 -> 0 bytes .../pic/산림과임업기술(임도)_p50_1.png | Bin 69953 -> 0 bytes .../pic/산림과임업기술(임도)_p50_2.png | Bin 35639 -> 0 bytes .../pic/산림과임업기술(임도)_p54_1.png | Bin 66443 -> 0 bytes .../pic/산림과임업기술(임도)_p54_2.png | Bin 34281 -> 0 bytes .../pic/산림과임업기술(임도)_p57_1.png | Bin 2345 -> 0 bytes .../pic/산림과임업기술(임도)_p57_2.png | Bin 135913 -> 0 bytes .../pic/산림과임업기술(임도)_p59_1.png | Bin 8194 -> 0 bytes .../pic/산림과임업기술(임도)_p5_1.png | Bin 11295 -> 0 bytes .../pic/산림과임업기술(임도)_p60_1.png | Bin 26004 -> 0 bytes .../pic/산림과임업기술(임도)_p61_1.png | Bin 13082 -> 0 bytes .../pic/산림과임업기술(임도)_p61_2.png | Bin 11581 -> 0 bytes .../pic/산림과임업기술(임도)_p62_1.png | Bin 7744 -> 0 bytes .../pic/산림과임업기술(임도)_p63_1.png | Bin 10395 -> 0 bytes .../pic/산림과임업기술(임도)_p64_1.png | Bin 29033 -> 0 bytes .../pic/산림과임업기술(임도)_p65_1.png | Bin 18232 -> 0 bytes .../pic/산림과임업기술(임도)_p65_2.png | Bin 26179 -> 0 bytes .../pic/산림과임업기술(임도)_p65_3.png | Bin 29053 -> 0 bytes .../pic/산림과임업기술(임도)_p66_1.png | Bin 11312 -> 0 bytes .../pic/산림과임업기술(임도)_p66_2.png | Bin 13472 -> 0 bytes .../pic/산림과임업기술(임도)_p68_1.png | Bin 16664 -> 0 bytes .../pic/산림과임업기술(임도)_p71_1.png | Bin 21850 -> 0 bytes .../pic/산림과임업기술(임도)_p71_2.png | Bin 14344 -> 0 bytes .../pic/산림과임업기술(임도)_p72_1.png | Bin 12644 -> 0 bytes .../pic/산림과임업기술(임도)_p72_2.png | Bin 19815 -> 0 bytes .../pic/산림과임업기술(임도)_p73_1.png | Bin 19963 -> 0 bytes .../pic/산림과임업기술(임도)_p75_1.png | Bin 18334 -> 0 bytes .../pic/산림과임업기술(임도)_p76_1.png | Bin 29497 -> 0 bytes .../pic/산림과임업기술(임도)_p77_1.png | Bin 32108 -> 0 bytes .../pic/산림과임업기술(임도)_p77_2.png | Bin 18863 -> 0 bytes .../pic/산림과임업기술(임도)_p78_1.png | Bin 15939 -> 0 bytes .../pic/산림과임업기술(임도)_p78_2.png | Bin 12398 -> 0 bytes .../pic/산림과임업기술(임도)_p79_1.png | Bin 14291 -> 0 bytes .../pic/산림과임업기술(임도)_p79_2.png | Bin 5883 -> 0 bytes .../pic/산림과임업기술(임도)_p80_1.png | Bin 19210 -> 0 bytes .../pic/산림과임업기술(임도)_p83_1.png | Bin 30857 -> 0 bytes .../pic/산림과임업기술(임도)_p83_2.png | Bin 98838 -> 0 bytes .../pic/산림과임업기술(임도)_p84_1.png | Bin 20641 -> 0 bytes .../pic/산림과임업기술(임도)_p89_1.png | Bin 19829 -> 0 bytes .../pic/산림과임업기술(임도)_p90_1.png | Bin 2473 -> 0 bytes .../pic/산림과임업기술(임도)_p91_1.png | Bin 7479 -> 0 bytes .../pic/산림과임업기술(임도)_p92_1.png | Bin 161709 -> 0 bytes .../pic/산림과임업기술(임도)_p93_1.png | Bin 99968 -> 0 bytes .../pic/산림과임업기술(임도)_p94_1.png | Bin 19944 -> 0 bytes .../pic/산림과임업기술(임도)_p98_1.png | Bin 14318 -> 0 bytes .../pic/산림과임업기술(임도)_p98_2.png | Bin 13401 -> 0 bytes .../pic/설계_그림5-2-59_합경거와합위거.png | Bin 0 -> 11623 bytes ...설계_그림5-2-60_탄젠트값트래버어스제도.png | Bin 0 -> 16147 bytes ...계_그림5-2-61_삼각함수값트래버어스제도.png | Bin 0 -> 8899 bytes .../pic/설계_그림5-2-62_평면도작성예.png | Bin 0 -> 221925 bytes .../pic/설계_그림5-2-63_종단면도작성예.png | Bin 0 -> 87523 bytes .../설계_그림5-2-64_평면선형과종단선형.png | Bin 0 -> 28867 bytes .../pic/설계_그림5-2-66_구조물도.png | Bin 35934 -> 16120 bytes .../pic/설계_그림5-2-67_표준도작성예.png | Bin 0 -> 17716 bytes .../pic/설계_그림5-2-68_용지도작성예.png | Bin 0 -> 45674 bytes .../pic/설계_그림5-2-69_사각주의점고.png | Bin 0 -> 11052 bytes .../pic/설계_그림5-2-70_Box단면도.png | Bin 54115 -> 17722 bytes .../pic/설계_그림5-2-71_유토곡선.png | Bin 0 -> 44969 bytes .../pic/설계_그림5-2-72_FRNET흐름도.png | Bin 28498 -> 36006 bytes .../pic/설계_그림5-2-73_STmate흐름도.png | Bin 96282 -> 37162 bytes .../pic/설계_그림5-2-74-75_도면흐름도.png | Bin 51013 -> 0 bytes .../pic/설계_그림5-2-74_종단면도흐름도.png | Bin 0 -> 31877 bytes .../pic/설계_그림5-2-75_횡단면도흐름도.png | Bin 0 -> 28016 bytes .../pic/설계_그림5-2-76-77_토적내역흐름도.png | Bin 79273 -> 0 bytes .../pic/설계_그림5-2-76_토적계산흐름도.png | Bin 0 -> 19572 bytes .../pic/설계_그림5-2-77_내역서흐름도.png | Bin 0 -> 44498 bytes .../pic/설계_그림5-2-78_Earthwork흐름도.png | Bin 25355 -> 37228 bytes .../시공_그림5-2-100_개거의종류와모형2.png | Bin 0 -> 69424 bytes .../pic/시공_그림5-2-101_사면배수시설.png | Bin 0 -> 29909 bytes .../pic/시공_그림5-2-102_돌망태사용예.png | Bin 0 -> 53002 bytes .../시공_그림5-2-79_본바닥상태와절취사면.png | Bin 0 -> 29160 bytes ...시공_그림5-2-81_편절토편성토접속횡단면.png | Bin 0 -> 25301 bytes .../시공_그림5-2-82_절토성토접속종단면.png | Bin 0 -> 18801 bytes .../시공_그림5-2-84_보조기층두께설계곡선.png | Bin 0 -> 23649 bytes .../pic/시공_그림5-2-85_콘크리트포장줄눈.png | Bin 0 -> 33082 bytes .../pic/시공_그림5-2-86_블록설치방법.png | Bin 0 -> 29758 bytes .../pic/시공_그림5-2-87_자갈도시공법.png | Bin 0 -> 18773 bytes .../pic/시공_그림5-2-88_교대와교각구조_01.png | Bin 0 -> 4679 bytes .../pic/시공_그림5-2-88_교대와교각구조_02.png | Bin 0 -> 7173 bytes .../pic/시공_그림5-2-89_교대의종류.png | Bin 0 -> 40943 bytes .../pic/시공_그림5-2-90_교각의종류_01.png | Bin 0 -> 12478 bytes .../pic/시공_그림5-2-90_교각의종류_02.png | Bin 0 -> 11661 bytes .../pic/시공_그림5-2-90_교각의종류_03.png | Bin 0 -> 6785 bytes .../pic/시공_그림5-2-90_교각의종류_04.png | Bin 0 -> 10459 bytes .../pic/시공_그림5-2-91_교량의종류_01.png | Bin 0 -> 16309 bytes .../pic/시공_그림5-2-91_교량의종류_02.png | Bin 0 -> 34371 bytes .../pic/시공_그림5-2-91_교량의종류_03.png | Bin 0 -> 13645 bytes .../pic/시공_그림5-2-91_교량의종류_04.png | Bin 0 -> 18942 bytes .../pic/시공_그림5-2-91_교량의종류_05.png | Bin 0 -> 20623 bytes .../pic/시공_그림5-2-91_교량의종류_06.png | Bin 0 -> 15301 bytes .../pic/시공_그림5-2-91_교량의종류_07.png | Bin 0 -> 16045 bytes .../pic/시공_그림5-2-91_교량의종류_08.png | Bin 0 -> 11912 bytes .../pic/시공_그림5-2-91_교량의종류_09.png | Bin 0 -> 108792 bytes .../pic/시공_그림5-2-92_옹벽의종류.png | Bin 0 -> 59848 bytes .../pic/시공_그림5-2-93_임도배수의종류.png | Bin 0 -> 34103 bytes .../pic/시공_그림5-2-94_막파기측구.png | Bin 0 -> 15950 bytes .../pic/시공_그림5-2-95_붙임측구.png | Bin 0 -> 13051 bytes .../pic/시공_그림5-2-96_콘크리트측구.png | Bin 0 -> 33460 bytes .../시공_그림5-2-97_횡단배수구와측구배치.png | Bin 0 -> 18916 bytes .../pic/시공_그림5-2-98_암거의모형.png | Bin 0 -> 23894 bytes .../pic/시공_그림5-2-99_개거의종류와모형1.png | Bin 0 -> 49136 bytes .../pic/시공_옹벽하중도_01.png | Bin 0 -> 5223 bytes .../pic/시공_옹벽하중도_02.png | Bin 0 -> 2713 bytes .../pic/시공_옹벽하중도_03.png | Bin 0 -> 4040 bytes .../pic/시공_옹벽하중도_04.png | Bin 0 -> 4342 bytes .../pic/시공_옹벽하중도_05.png | Bin 0 -> 3337 bytes ...도계획_그림5-2-10_중경사지역의계곡임도.png | Bin 0 -> 13422 bytes ...계획_그림5-2-11_편평한주계곡의계곡임도.png | Bin 0 -> 17205 bytes ...도계획_그림5-2-12_완경사지형의평형노망.png | Bin 0 -> 15535 bytes ...도계획_그림5-2-13_급경사긴사면의사행형.png | Bin 0 -> 15487 bytes .../임도계획_그림5-2-14_능선임도의어골형.png | Bin 0 -> 13590 bytes .../pic/임도계획_그림5-2-15_능선임도형.png | Bin 0 -> 10948 bytes .../임도계획_그림5-2-16_산정부순환임도형.png | Bin 0 -> 13265 bytes ...도계획_그림5-2-17_계곡분지의순환임도형.png | Bin 0 -> 19078 bytes ...림5-2-18_반대사면으로부터의임도이용형.png | Bin 0 -> 20247 bytes ...도계획_그림5-2-19_양각기에의한물매설정.png | Bin 0 -> 8157 bytes .../pic/임도계획_그림5-2-20_양각기사용예.png | Bin 0 -> 13534 bytes .../pic/임도계획_그림5-2-21_임도망계획도.png | Bin 0 -> 85021 bytes ...계획_그림5-2-23_노망배치형태별개발지수.png | Bin 0 -> 17112 bytes ...임도계획_그림5-2-24_임도운반비의추정도.png | Bin 0 -> 15969 bytes ...계획_그림5-2-7_적정임도가격산출모식도.png | Bin 0 -> 11376 bytes ..._그림5-2-8_ha당임목축적별적정임도밀도.png | Bin 0 -> 15432 bytes ...도계획_그림5-2-9_급경사지역의계곡임도.png | Bin 0 -> 15891 bytes .../임도구조_그림5-2-4_자동차바퀴의구동형.png | Bin 0 -> 16624 bytes .../pic/임도구조_그림5-2-5_완화구간의형태.png | Bin 0 -> 14337 bytes ..._그림5-2-6_굴곡부의가시거리확보방법_01.png | Bin 0 -> 10674 bytes ..._그림5-2-6_굴곡부의가시거리확보방법_02.png | Bin 0 -> 13240 bytes .../총론_그림5-2-1_임도의기능과이용특성.png | Bin 0 -> 28763 bytes .../총론_그림5-2-2_임내생산물의운송체계.png | Bin 0 -> 37810 bytes 256 files changed, 386 insertions(+), 261 deletions(-) create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-30_토우로프.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-31_개산물매와수정.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-35_편각법트래버어스측량.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-37_트래버어스측정치조건.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-42_위거와경거의관계.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-44_곡선부의구조.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-53_복심곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-58_종단곡선측점종횡거.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-1_채석적지부위별명칭.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p101_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p105_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p118_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p122_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p122_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p12_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p131_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p132_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p134_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p135_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p136_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p136_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p137_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p138_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p138_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p138_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p138_4.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_4.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_5.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_6.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_7.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p139_8.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p13_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p140_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p142_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p143_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p143_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p143_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p143_4.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p143_5.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p145_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p149_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p149_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p150_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p151_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p151_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p151_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p152_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p153_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p154_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p159_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p164_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p164_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p165_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p166_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p168_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p168_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p172_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p172_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p176_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p18_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p18_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p24_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p25_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p2_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p30_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p30_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p30_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p31_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p31_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p31_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p31_4.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p32_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p32_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p32_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p33_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p35_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p35_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p42_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p43_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p50_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p50_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p54_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p54_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p57_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p57_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p59_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p5_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p60_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p61_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p61_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p62_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p63_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p64_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p65_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p65_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p65_3.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p66_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p66_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p68_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p71_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p71_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p72_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p72_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p73_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p75_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p76_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p77_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p77_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p78_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p78_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p79_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p79_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p80_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p83_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p83_2.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p84_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p89_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p90_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p91_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p92_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p93_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p94_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p98_1.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/산림과임업기술(임도)_p98_2.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-59_합경거와합위거.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-60_탄젠트값트래버어스제도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-61_삼각함수값트래버어스제도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-62_평면도작성예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-63_종단면도작성예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-64_평면선형과종단선형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-67_표준도작성예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-68_용지도작성예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-69_사각주의점고.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-71_유토곡선.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-74-75_도면흐름도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-74_종단면도흐름도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-75_횡단면도흐름도.png delete mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-76-77_토적내역흐름도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-76_토적계산흐름도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-77_내역서흐름도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-100_개거의종류와모형2.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-101_사면배수시설.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-102_돌망태사용예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-79_본바닥상태와절취사면.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-81_편절토편성토접속횡단면.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-82_절토성토접속종단면.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-84_보조기층두께설계곡선.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-85_콘크리트포장줄눈.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-86_블록설치방법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-87_자갈도시공법.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-89_교대의종류.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_03.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_04.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_03.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_04.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_05.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_06.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_07.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_08.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_09.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-92_옹벽의종류.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-93_임도배수의종류.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-94_막파기측구.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-95_붙임측구.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-96_콘크리트측구.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-97_횡단배수구와측구배치.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-98_암거의모형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-99_개거의종류와모형1.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_03.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_04.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_05.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-10_중경사지역의계곡임도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-12_완경사지형의평형노망.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-13_급경사긴사면의사행형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-14_능선임도의어골형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-15_능선임도형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-16_산정부순환임도형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-17_계곡분지의순환임도형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-19_양각기에의한물매설정.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-20_양각기사용예.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-21_임도망계획도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-23_노망배치형태별개발지수.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-24_임도운반비의추정도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-7_적정임도가격산출모식도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-9_급경사지역의계곡임도.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-4_자동차바퀴의구동형.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-5_완화구간의형태.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-1_임도의기능과이용특성.png create mode 100644 resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-2_임내생산물의운송체계.png diff --git a/resources/knowledge/original/_pipeline/data/qc_report.json b/resources/knowledge/original/_pipeline/data/qc_report.json index 054c2a2d..70450d8b 100644 --- a/resources/knowledge/original/_pipeline/data/qc_report.json +++ b/resources/knowledge/original/_pipeline/data/qc_report.json @@ -1,4 +1,76 @@ [ + { + "file": "법률/사방사업법 시행령/별표/별표1_사방사업 타당성평가의 기준·방법·대상사업 등(제4조의2 관련).md", + "issues": { + "띄어쓰기": 0.566 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표10_기초번호판의 세부규격(제12조제2항 관련).md", + "issues": { + "띄어쓰기": 0.176 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표13_건물번호판의 구성(제17조제2항 관련).md", + "issues": { + "띄어쓰기": 0.671 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표14_건물번호판의 세부규격(제17조제2항 관련).md", + "issues": { + "띄어쓰기": 0.266 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표16_국가지점번호판의 표시사항 및 세부규격(제24조제1항 관련).md", + "issues": { + "띄어쓰기": 0.209 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표18_사물주소판의 구성(제26조제3항 관련).md", + "issues": { + "띄어쓰기": 0.577 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표1_도로명판의 크기(제4조제2항 관련).md", + "issues": { + "띄어쓰기": 0.415 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표22_내부도로 도로명판의 구성(제31조제1항제1호가목 관련).md", + "issues": { + "띄어쓰기": 0.476 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표24_내부도로 기초번호판의 구성(제31조제1항제1호나목 관련).md", + "issues": { + "띄어쓰기": 0.608 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표28_내부도로 사물주소판의 구성(제31조제1항제1호라목 관련).md", + "issues": { + "띄어쓰기": 0.634 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표2_도로명판의 구성(제5조제2항 관련).md", + "issues": { + "띄어쓰기": 0.743 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표3_도로명판의 세부규격(제5조제2항 관련).md", + "issues": { + "띄어쓰기": 0.273 + } + }, { "file": "행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/별표/별표0_산림사업 표준품셈.md", "issues": { diff --git a/resources/knowledge/original/_pipeline/pdf2md.py b/resources/knowledge/original/_pipeline/pdf2md.py index 8e7ea80e..898bcb01 100644 --- a/resources/knowledge/original/_pipeline/pdf2md.py +++ b/resources/knowledge/original/_pipeline/pdf2md.py @@ -84,25 +84,31 @@ MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘 def _images(page, pno, doc, picdir, stem): - """페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환.""" + """PDF 화면에 보이는 이미지 영역을 렌더링하고 (rect, ref) 리스트 반환. + + 원시 xref를 직접 저장하면 이미지 마스크가 검게 나오거나 이미지 위에 별도로 + 그려진 문자·선이 빠질 수 있다. 페이지의 실제 배치 사각형을 렌더링하여 화면과 + 같은 합성 결과를 보존한다. + """ out = [] idx = 0 for im in page.get_images(full=True): xref = im[0] rects = page.get_image_rects(xref) r = rects[0] if rects else pymupdf.Rect(0, 0, 0, 0) + if r.is_empty or r.is_infinite: + continue try: - px = pymupdf.Pixmap(doc, xref) + source = pymupdf.Pixmap(doc, xref) except Exception: continue - if px.width < MIN_IMG or px.height < MIN_IMG: + if source.width < MIN_IMG or source.height < MIN_IMG: continue idx += 1 picdir.mkdir(parents=True, exist_ok=True) fn = f"{stem}_p{pno + 1}_{idx}.png" try: - if px.n - px.alpha >= 4: # CMYK 등 → RGB - px = pymupdf.Pixmap(pymupdf.csRGB, px) + px = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), clip=r, alpha=False) px.save(str(picdir / fn)) except Exception: continue diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md index 5f87d8d1..751c507b 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md @@ -8,7 +8,7 @@ 임도의 기능은 크게 이동기능, 접근기능, 공간기능의 3가지로 구분된다. 이동기능은 교통류를 신속하고 원활하게 처리해 주는 기능으로서 임내 또는 주변 토지에서 생산된 물류를 신속하게 유통시키고, 사람들의 왕래와 여가활동을 위하여 신속성, 안정성, 편리성을 도모하는 것으로서 간선임도, 연결임도(Access Forest Road)가 여기에 해당된다. 접근기능은 임지이용의 활성화를 촉진시키는 기능으로서 임내의 구석구석까지 접근하여 산림 작업과 생산활동에 직접 이용되는 것으로서 지선임도, 경영임도(Management Forest Road)가 이에 해당된다. 공간기능은 제한된 공간을 갖는 집약적인 임업에서 집재, 집적, 주차 등의 공공용지나 휴양림에서 광장 등의 생활공간으로 사용된다. -![그림 2-1](<../pic/산림과임업기술(임도)_p2_1.png>) +![총론_그림5-2-1_임도의기능과이용특성](<../pic/총론_그림5-2-1_임도의기능과이용특성.png>) 그림 5-2-1. 임도의 기능과 이용특성 @@ -55,7 +55,7 @@ 산림을 통과하고 있는 도로는 도로법에 의한 고속국도, 일반국도, 특별시도·광역시도, 지방도, 시도, 군도, 구도, 농어촌도로정비법에 의한 면도, 리도, 농도 등 여러 종류의 공도와 사도법에 의한 사도, 타 법령에 의하여 시설되는 목적도(目的道)가 있다. 임도는 산림내 또는 산림에 연결 시설하는 차도이므로 임내에 있을 수도 있고 임외에서 공도와 임지를 연결할 수도 있다. 따라서 임도망은 임산물이 임지에서 생산되어 공도를 경유하여 시장이나 제재공장 등 수요처까지 운반될 수 있는 연결노망의 일부분이라고 할 수 있을 것이다. 도로(공도)에서부터 임내까지 도달하는 것을 임내 접근수단이라고 하고, 산림내에서 생산되는 임산물이 공도를 거쳐 시장·공장 등 수요처까지 운반하는 것을 반출 또는 운송수단이라고 하며 그 운송체계는 (그림 5-2-2)와 같이 임산물이 생산된 위치와 임도망의 상태에 따라서 여러 가지의 경로로 가정할 수 있다. -![그림 5-1](<../pic/산림과임업기술(임도)_p5_1.png>) +![총론_그림5-2-2_임내생산물의운송체계](<../pic/총론_그림5-2-2_임내생산물의운송체계.png>) 운재로 작업도 임산물 집 재 diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md index f2269574..40ba96aa 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md @@ -1,8 +1,6 @@ # 2. 임도구조 -임도의 구조(Forest Road Structure)를 계획하고자 할 때에는 토목적인 면, 경제적인 면 및 환 - -경적인 면을 고려하여야 하며, 임산물의 종류, 집재방법, 수송시기 및 수량에 따라서도 구조나 규격이 달라질 수 있다. 임도상을 운행할 수 있는 차량의 제원(諸元)은 (표 5-2-2)와 같이 자동차의 종류에 따라 다르므로 이용목적에 따라 통행할 수 있는 자동차 가운데 최대의 규격을 적용한다. 표 5-2-2. 설계차량의 제원(단위:m) +임도의 구조(Forest Road Structure)를 계획하고자 할 때에는 토목적인 면, 경제적인 면 및 환경적인 면을 고려하여야 하며, 임산물의 종류, 집재방법, 수송시기 및 수량에 따라서도 구조나 규격이 달라질 수 있다. 임도상을 운행할 수 있는 차량의 제원(諸元)은 (표 5-2-2)와 같이 자동차의 종류에 따라 다르므로 이용목적에 따라 통행할 수 있는 자동차 가운데 최대의 규격을 적용한다. 표 5-2-2. 설계차량의 제원(단위:m) | 구 분 종 류 | 길이 | 너비 | 높이 | 앞내민거리 | 앞뒤바퀴 거 리 | 뒤내민거리 | 최소회전 반 지 름 | |---|---|---|---|---|---|---|---| @@ -70,7 +68,7 @@ W = 2(B+b) + b0 - 2b′여기서, W : 차도폭(m)B : 자동차의 폭(m)b 2 / 2R) - 0.5 ε= (L -![그림 12-1](<../pic/산림과임업기술(임도)_p12_1.png>) +![임도구조_그림5-2-4_자동차바퀴의구동형](<../pic/임도구조_그림5-2-4_자동차바퀴의구동형.png>) L 여기서, ε: 확폭량(m)A 중심선 R : 중심선의 곡선반지름(m)L : 차량 앞면에서 뒷차축까지 거리(m)ε(= 8m 적용)R(예제) 광릉시험림내 임도의 최소곡선반지름은 20m이 @@ -98,7 +96,7 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : |---|---|---|---|---|---| | 완화구간의 길이(m) | 20 | 25 | 35 | 40 | 50 | -![그림 13-1](<../pic/산림과임업기술(임도)_p13_1.png>) +![임도구조_그림5-2-5_완화구간의형태](<../pic/임도구조_그림5-2-5_완화구간의형태.png>) 원곡선 R=∞완화곡선 R이정량 직선 직선 원곡선 완화구간 그림 5-2-5. 완화구간의 형태 @@ -109,7 +107,7 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : - ② 1 : n 또는 1 / n : 높이 1에 대하여 수평거리 n으로 나눈 것 - ③ n % : 수평거리 100에 대한 n의 고저차를 갖는 백분율 - ④ n ‰ (per mill) : 수평거리 1000에 대한 n의 고저차를 갖는 천분율 -- ⑤ 비탈물매 : 수직높이 1에 대한 수평거리의 비(比)로서 하할법 또는 할푼법이라 함 ※ 15/100 = 15% = 150‰ = tan 8。31' 50.8" = 1/6.67 = 6할 7푼 +- ⑤ 비탈물매 : 수직높이 1에 대한 수평거리의 비(比)로서 하할법 또는 할푼법이라 함 ※ 15/100 = 15% = 150‰ = tan 8°31' 50.8" = 1/6.67 = 6할 7푼 #### 1) 곡선부 외쪽물매(片勾配)의 산출 자동차가 원심력에 의하여 도로의 바깥쪽으로 뛰쳐나가려는 힘이 생기므로 이를 방지하기 위하여 곡선부에서는 외쪽물매(Super Elevation, Oneway Grade)를 설치한다. 외쪽물매는 노면 바깥쪽이 안쪽보다 높게 설치되도록 횡단선형을 조정한다. V2i =- f127R 여기서, i : 곡선부의 외쪽물매(%/100)V : 설계속도(km/hr)R : 곡선반지름(m)f : 가로미끄러짐에 대한 노면과 타이어의 마찰계수{(다)-2의 마찰계수 참조}표 5-2-5. 설계속도별 곡선반지름별 한계 외쪽물매(%) @@ -128,7 +126,17 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : - ④ 횡단물매의 형상은 직선, 원호, 2차 포물선, 쌍곡선 등이 있으나, 아스팔트 포장도는 쌍곡선, 자갈도는 포물선, 콘크리트 포장도는 직선이 주로 사용되고 있다. #### 3) 합성물매의 산출 -종단물매와 외쪽물매 또는 횡단물매를 제곱하여 합한 값의 제곱근을 합성물매(合成勾配 :Composite Gradient)라고 하며, 자동차가 곡선부 구간을 주행할 경우에는 보통 노면보다 더 급한 합성물매가 발생되기 때문에 자동차의 안전운행에 위험부담을 주거나, 실은 짐이 편중되어 곡선저항에 의한 차량의 저항이 커져서 주행에 좋지 않은 영향을 미치므로 이를 어느 한계까지 제한할 필요가 있다. 대개 합성물매는 12%이하로 하는 것이 좋으며 부득이한 경우에도 13∼15% 이하로 하는 것이 좋다. 이의 합성물매로서 산출한 종단물매는 (표 5-2-6)과 같다.2 + jS =i2 여기서, S : 합성물매(%)i : 외쪽 또는 횡단물매(%)j : 종단물매(%)표 5-2-6. 한계 합성물매와 외쪽물매에 의한 종단물매(%) +종단물매와 외쪽물매 또는 횡단물매를 제곱하여 합한 값의 제곱근을 합성물매(合成勾配: Composite Gradient)라고 한다. 합성물매는 다음 식으로 산출한다. + +$$S=\sqrt{i^2+j^2}$$ + +- $S$: 합성물매(%) +- $i$: 외쪽물매 또는 횡단물매(%) +- $j$: 종단물매(%) + +대개 합성물매는 12% 이하로 하는 것이 좋으며 부득이한 경우에도 13∼15% 이하로 하는 것이 좋다. 합성물매로 산출한 종단물매는 표 5-2-6과 같다. + +표 5-2-6. 한계 합성물매와 외쪽물매에 의한 종단물매(%) | 한계합성물매(%) | 외쪽물매 또는 횡단물매(%) | | | | | | |---|---|---|---|---|---|---| @@ -137,7 +145,9 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : | 13 | 10.2 | 11.5 | 12.0 | 12.4 | 12.6 | 12.8 | | 15 | 12.6 | 13.7 | 14.1 | 14.5 | 14.7 | 14.9 | -(예제) 산악지 임도에서 종단물매 9%의 구간에 곡선부의 외쪽물매를 7%로 설치하고자 할 때 합성물매를 산출하여라. (풀이) S =72+9 = 11.4%2 +(예제) 산악지 임도에서 종단물매 9%의 구간에 곡선부의 외쪽물매를 7%로 설치하고자 할 때 합성물매를 산출하여라. + +$$S=\sqrt{7^2+9^2}=11.4\%$$ #### 4) 종단물매의 산출 @@ -148,23 +158,37 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : - ② 외쪽물매 6%이상일 경우 : G - (120 / R) > S 여기서, G : 설계기준에 의한 최대 종단물매(%)R : 곡선반지름(m)S : 완화구간을 포함한 곡선길이의 종단물매(%)(예제) 광능시험림 임도에서 종단물매가 6%인 곡선부의 곡선반지름이 50m 이다. 외쪽물매를 6%라 할 때 물매조정의 필요여부를 검토하라(단, 2급임도 표준물매는 9%). (풀이) 외쪽물매 6%이상일 경우의 식에 의하면 9-(120/50) = 6.6 > 6%이므로 이 구간의 물매는 수정할 필요가 없다. ##### 나) 합성물매에서 곡선부 종단물매의 산출 -2 - ij =S2 여기서, j : 종단물매(%)S : 합성물매(%)i : 외쪽물매 또는 횡단물매(%)(예제) 광능시험림 임도는 합성물매를 12%이하로 설정한다. 이때 외쪽물매를 자동차의 통행 안전상 6%로 적용한다면 종단물매는 몇 %인가 ? (풀이) j =122-6 = 10.4 ≒ 10%2 +$$j=\sqrt{S^2-i^2}$$ + +(예제) 광릉시험림 임도는 합성물매를 12% 이하로 설정한다. 외쪽물매를 6%로 적용할 때 종단물매는 다음과 같다. + +$$j=\sqrt{12^2-6^2}=10.4\%\fallingdotseq10\%$$ ##### 다) 물매곡율비의 산출 -곡선부의 내각이 예각일 경우에는 급한 곡선이 설정되기 때문에 곡선부를 통과하는 자동차의 안정성에 영향을 크게 미치게 된다. 이를 보완하기 위하여 곡선반지름을 크게 하면 할수록 임도의 구조는 양호해지지만 급경사지에서는 성·절토량이 증가하게 되므로 공사비는 증가하게 된다. K = R / I여기서, R : 곡선반지름(m)I : 종단물매(%)K : 물매곡율비 +곡선부의 내각이 예각일 경우에는 급한 곡선이 설정되기 때문에 곡선부를 통과하는 자동차의 안정성에 영향을 크게 미치게 된다. 물매곡율비는 다음 식으로 산출한다. + +$$K=\frac{R}{I}$$ + +- $R$: 곡선반지름(m) +- $I$: 종단물매(%) +- $K$: 물매곡율비 | 구 분 | 물매곡율비 | 구 분 | 물매곡율비 | |---|---|---|---| | 평탄지 도로 | 7.5 이상 | 산악지 도로 | 4.0 이상 | | 구릉지 도로 | 6.0 이상 | 임 도 | 3.0 이상 | -일반적으로 외거(External Distance)는 교점(Intersection Point)에서 중곡점(Middle of Curve)까지의 거리로써, 그것의 크기는 내각의 크기에 반비례한다. 곡선반지름 10m 기준으로 볼 때 외거의 크기는 내각이 55。이면 11.657m, 105。는 2.605m, 135。는 0.824 m, 155。는 0.234m로서 내각이 클수록 교점과 중곡점은 가까워지므로 곡선반지름을 크게 적용할 수 있다. 따라서 산악지 지형조건에서는 내각이 예각에 가까울 경우에는 외거가 커지므로 곡선반지름을 크게 설치할 수 없기 때문에 구조를 보완하여 자동차 통행의 안정성이 보장될 수 있도록 물 매곡율비를 적용한다. 예를들면 물매곡율비(곡선반지름/종단물매)를 3.0이상으로 유지시키도록 설치·시공하려면 곡선반지름이 10m일 때 종단물매는 3%내외로 조정할 수 밖에 없고, 내각이 105。이상으로 클 경우에는 곡선반지름을 20m로 하여도 6%내외, 155。이상일 경우에는 직선부와 거의 같도록 설치하여도 된다. +일반적으로 외거(External Distance)는 교점(Intersection Point)에서 중곡점(Middle of Curve)까지의 거리로써, 그것의 크기는 내각의 크기에 반비례한다. 곡선반지름 10m 기준으로 볼 때 외거의 크기는 내각이 55°이면 11.657m, 105°는 2.605m, 135°는 0.824 m, 155°는 0.234m로서 내각이 클수록 교점과 중곡점은 가까워지므로 곡선반지름을 크게 적용할 수 있다. 따라서 산악지 지형조건에서는 내각이 예각에 가까울 경우에는 외거가 커지므로 곡선반지름을 크게 설치할 수 없기 때문에 구조를 보완하여 자동차 통행의 안정성이 보장될 수 있도록 물 매곡율비를 적용한다. 예를들면 물매곡율비(곡선반지름/종단물매)를 3.0이상으로 유지시키도록 설치·시공하려면 곡선반지름이 10m일 때 종단물매는 3%내외로 조정할 수 밖에 없고, 내각이 105°이상으로 클 경우에는 곡선반지름을 20m로 하여도 6%내외, 155°이상일 경우에는 직선부와 거의 같도록 설치하여도 된다. ### 사. 가시거리 #### 1) 제동정지 가시거리(stopping S.D.)의 산출 ##### 가) 대상물이 고정되어 있을 경우 -2 / fS = 0.694V + 0.00394V 여기서, S : 가시거리(m)V : 주행속도(km/hr)f : 타이어와 노면의 가로미끄러짐 마찰계수 +$$S=0.694V+\frac{0.00394V^2}{f}$$ + +- $S$: 가시거리(m) +- $V$: 주행속도(km/hr) +- $f$: 타이어와 노면의 가로미끄러짐 마찰계수 | 노면상태 | 설계속도(km/hr) | | | |---|---|---|---| @@ -172,9 +196,7 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : | 건 조 | 0.65 | 0.64 | 0.61 | | 습 윤 | 0.44 | 0.40 | 0.35 | -※ 0.694 : 운전수의 반응시간(t)이 0.25초일 때 t / 3.6에 의하여 산출한 값 0.00394 : 중력가속도(g) 9.8m/sec2일 때 1 / (2g×3.6 - -#### 2) 에 의하여 산출한 값 +※ 0.694는 운전수의 반응시간($t$)이 2.5초일 때 $t/3.6$으로 산출한 값이며, 0.00394는 중력가속도($g$)가 9.8m/sec²일 때 $1/(2g\times3.6^2)$으로 산출한 값이다. 표 5-2-7. 제동정지 가시거리(m) @@ -185,21 +207,36 @@ L1L222 ε= ε1+ε2 =+2R 2R′여기서, ε: 전체확폭량(m)ε1 : ##### 나) 양쪽에서 마주오는 자동차가 동시에 정지할 경우 -S = 1.388V + 0.00788V2/ f(예제) 설계속도 40㎞/hr일 때 마찰계수(f)=0.40이라면 제동정지 가시거리는 얼마인가? (풀이) ① 양방향에서 주행할 경우 : S = 1.388×40+0.00788×402/0.40 = 87.40m +$$S=1.388V+\frac{0.00788V^2}{f}$$ + +(예제) 설계속도 40㎞/hr일 때 마찰계수($f$)=0.40이라면 제동정지 가시거리는 얼마인가? + +- ① 양방향에서 주행할 경우: $S=1.388\times40+(0.00788\times40^2)/0.40=87.40\,\mathrm{m}$ - ② 대상물이 고정되어 있는 경우 : S = 0.694×40+0.00394×402/0.40 = 43.52m #### 2) 곡선부 가시거리의 산출 -(그림 5-2-6의 ㉠)과 같이 곡선부의 안쪽에 절토부가 있을 때에는 시야가 가려지므로 (그림5-2-6의 ㉡)과 같이 층따기를 하여 시야를 넓혀야 할 필요가 있다. S = Q·R = 0.01754·θ·Rd = R·{1 - cos(θ/ 2)}= R·{1 - cos(28.7·S / R)}여기서, S : 가시거리(m) Q : 호도법에 의한 중심각 R : 곡선반지름(m) d : 중심선에서 안쪽으로 층따기를 하여야 할 거리(m) +(그림 5-2-6의 ㉠)과 같이 곡선부의 안쪽에 절토부가 있을 때에는 시야가 가려지므로 (그림 5-2-6의 ㉡)과 같이 층따기를 하여 시야를 넓혀야 할 필요가 있다. -![그림 18-1](<../pic/산림과임업기술(임도)_p18_1.png>) +$$S=Q\cdot R=0.01754\theta R$$ -S +$$d=R\left\{1-\cos\left(\frac{\theta}{2}\right)\right\}=R\left\{1-\cos\left(\frac{28.7S}{R}\right)\right\}$$ -![그림 18-2](<../pic/산림과임업기술(임도)_p18_2.png>) +- $S$: 가시거리(m) +- $Q$: 호도법에 의한 중심각 +- $R$: 곡선반지름(m) +- $d$: 중심선에서 안쪽으로 층따기를 하여야 할 거리(m) -층따기 dABd1.3m0.5~1.0mR θ㉠ 시준장해의 안전가시거리 ㉡ 안전가시거리 확보를 위한 층따기 그림 5-2-6. 굴곡부의 가시거리 확보방법(예제) 산림경영시범 단지내의 임도시설시 안전거리를 산출하려고 한다. 곡선의 내각이 45。, 반지름이 40m일 때 가시거리(S)와 안쪽 층따기 거리(d)를 구하여라. (풀이) ① S=0.01754×45。×40=31.57m +![임도구조_그림5-2-6_굴곡부의가시거리확보방법_01](<../pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png>) -- ② d=40×{1-cos(45。/2)}=3.0m +![임도구조_그림5-2-6_굴곡부의가시거리확보방법_02](<../pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png>) + +그림 5-2-6. 굴곡부의 가시거리 확보방법 + +(예제) 산림경영시범단지 내의 임도시설 시 안전거리를 산출하려고 한다. 곡선의 내각이 45°, 반지름이 40m일 때 가시거리($S$)와 안쪽 층따기 거리($d$)를 구하여라. + +- ① $S=0.01754\times45\times40=31.57\,\mathrm{m}$ + +- ② d=40×{1-cos(45°/2)}=3.0m ### 아. 주요 국가의 임도구조 주요 국가의 임도규정에 규정된 구조는 (표 5-2-8)과 같다. 표 5-2-8. 각국의 임도구조 diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md index 9142a178..74c49291 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md @@ -1,8 +1,6 @@ # 3. 임도계획 -임도의 계획은 임도망을 계획하고자 하는 구역 안에 임도를 어느 정도의 밀도로서 어떻게 배 - -치하는 것이 경제적이고 이용효율성이 높은 임도를 시설할 수 있는가를 알기 위하여 필요하다. 임도의 계획은 계획기간에 따라서 장기계획, 단기계획, 시공계획의 단계로 구분된다. +임도의 계획은 임도망을 계획하고자 하는 구역 안에 임도를 어느 정도의 밀도로서 어떻게 배치하는 것이 경제적이고 이용효율성이 높은 임도를 시설할 수 있는가를 알기 위하여 필요하다. 임도의 계획은 계획기간에 따라서 장기계획, 단기계획, 시공계획의 단계로 구분된다. - ① 장기 계획 지역 전체를 대상으로 하는 5년 이상의 계획기간으로서 해당지역 내의 목표임도밀도에 해당 @@ -88,7 +86,7 @@ 임도의 개설이 늘어감에 따라 임도밀도가 증가되어 집재비, 조재비, 관리비는 낮아지지만 임도개설비, 임도 유지관리비, 운재비는 증가한다. Matthews는 생산원가관리이론을 적용하여 임업 생산비 중에서 임도개설연장의 증감에 따라서 현격하게 변화되는 주벌의 집재비용과 임도개설 비의 합계를 가장 최소화시키는 적정임도밀도(Optimum Forest Road Density)와 적정임도간격(Optimum Forest Road Spacing)을 구명하였다. (그림 5-2-7)은 임도개설비와 집재비용의 합계 -![그림 24-1](<../pic/산림과임업기술(임도)_p24_1.png>) +![임도계획_그림5-2-7_적정임도가격산출모식도](<../pic/임도계획_그림5-2-7_적정임도가격산출모식도.png>) 합계비용 비용에 대한 손익분기점(Break-even Point)을 나집 재 비m3당타내고 있다. 즉, 임도간격이 크게 되면 단위재적 비당의 임도비용은 감소하지만 집재거리가 길어져용(원/m @@ -100,9 +98,9 @@ t : 작업왕복시간(분/m) L : 장비의 평균적재량(㎥)(예제) 광릉시험림의 평균 임목축적은 183㎥/ha, 임도개설비 44,548천원/㎞, 집재비 320원/m/ ㎥이라면 적정임도밀도는 얼마인가? (풀이) 임목축척이 원목상태로 될 때에는 임상과 영급에 따라 벌채율과 조재율이 변화되므로 여기서는 대부분이 장령림, 중경목 이상으로 보아 벌채율 70%, 조재율 80%를 적용하면 원목생산예정량은 183 × 0.7 × 0.8 = 102.5㎥/ha가 된다.102.5×320×1.5×1.25d = 50 = 58.7m/ha44,548 ※ 임목축적 증가에 따른 적정임도밀도의 변화 우리나라 산림의 실정으로 볼 때 임도밀도를 어떻게 증가시켜 나가는 것이 좋은지 알아보기 위하여 (그림 5-2-8)과 같이 경북 봉화 소재 국유림과 광릉시험림을 대상으로 경제성을 감안한 1ha당 임목축적별로 적정임도밀도를 산출하여 본 바 임목축적이 46㎥/㏊, 60㎥/㏊, 100㎥/㏊,150㎥/㏊, 200㎥/㏊으로 점차 증가함에 따라 적정임도밀도는 7.7m/㏊, 10m/㏊, 16m/㏊, 23m/㏊,29m/㏊까지 증가하는 것으로 나타났다.’94년말 현재 우리나라의 임목축적은 46㎥/㏊이다. 임목축적이 증가하여 60㎥/㏊이 될 때까지는 기본임도밀도인 10m/㏊로 가능하지만 그 이상 임목축적이 증가하면 임도밀도는 10m/㏊를 초과하게 된다. 그때에는 집약적인 경영이 가능할 수 있도록 계획적인 임도밀도 즉, 적정임도밀도 수준까지 설치하지 않으면 안된다. 이렇게 될 때 비로소 우리나라의 산림이 경제적인 기반조성을 갖춘 선진화된 산림의 형태를 갖추게 되는 것이다. -![그림 25-1](<../pic/산림과임업기술(임도)_p25_1.png>) +![임도계획_그림5-2-8_ha당임목축적별적정임도밀도](<../pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png>) -6050임도 40 밀 30도 20(m/ha)1000100200300400600ha당 임목축적(㎥/㏊)그림 5-2-8. ha당 임목축적별 적정임도밀도 +그림 5-2-8. ha당 임목축적별 적정임도밀도 #### 3) 지선임도밀도(Feeder Road Density) @@ -173,25 +171,27 @@ R·RD·(1+η)·(1+η′)RC =1,000V 여기서, RD : 실제 임도밀도(m/ha - ③ 다양성의 원칙 : 임업적·경제적 기능도 중요하지만 생태적, 환경적, 공익적인 기능도 함께 수용할 수 있도록 계획한다. ##### 가) 계곡임도형 -임지는 하단부로 부터 점차적으로 개발하는 것이 일반적이므로 계곡임도는 임지개발의 중추적인 역할을 한다. 홍수로 인한 유실을 방지하고 임도시설비용을 절감하기 위하여 계곡하단부에 설치하지 않고 약간 위인 산록부의 사면에 최대홍수위 보다 10m 높게 설치한다. 횡단사면은 항상 높은 지지력을 유지하여야 하므로 비교적 간단한 사면처리방법으로도 설치할 수 있는 곳이 바람직하다. (그림 5-2-9)는 계곡부에 급경사면이 있어 쉽게 산록부나 산복부로 노선을 이동시킬 수가 없거나 계상의 물매가 너무 급하여 계곡을 따라계 +임지는 하단부로부터 점차적으로 개발하는 것이 일반적이므로 계곡임도는 임지개발의 중추적인 역할을 한다. 홍수로 인한 유실을 방지하고 임도시설비용을 절감하기 위하여 계곡하단부에 설치하지 않고 약간 위인 산록부의 사면에 최대홍수위보다 10m 높게 설치한다. 횡단사면은 항상 높은 지지력을 유지하여야 하므로 비교적 간단한 사면처리방법으로도 설치할 수 있는 곳이 바람직하다. 그림 5-2-9는 계곡부에 급경사면이 있어 쉽게 산록부나 산복부로 노선을 이동시킬 수가 없거나 계상의 물매가 너무 급하여 계곡을 따라 계속적인 노선선정이 불가능한 경우에 흔히 설치할 수 있는 형으로서 배향곡선(Hairpin Curve)을 설치하여 상류 쪽으로 계속 진행한다. 그림 5-2-10은 큰 지류가 있는 계곡부에서 흔히 설치할 수 있는 일반적인 형태로서 주계곡의 계상물매가 한계 종단물매 이내이고 지류를 우회하여 노선을 설치할 수 있는 곳이다. 그림 5-2-11은 편평한 주계곡을 따라 주임도에서 분기하는 지선(부)임도의 연결형이다. 그림 5-2-11의 ㉠형은 소계곡의 계상물매가 주계곡과의 연결부위 물매가 완만한 경우에 설치할 수 있고, ㉡형은 연결부위 물매가 급한 경우에 주계곡 상단부에서 배향곡선으로 우회하여 소계곡으로 진입하는 형태이다. -![그림 30-1](<../pic/산림과임업기술(임도)_p30_1.png>) +![임도계획_그림5-2-9_급경사지역의계곡임도](<../pic/임도계획_그림5-2-9_급경사지역의계곡임도.png>) -사면배향곡선 속적인 노선선정이 불가능한 경우에 흔히 설치가능사면임도 한 형으로서 배향곡선(Hairpin Curve)을 설치하여 하향물매 상류쪽으로 계속 진행한다. (18~20%)(7~8%)계류(5~6%)(그림 5-2-10)은 큰 지류가 있는 계곡부에서 흔 급경사부분 계곡배향곡선히 설치할 수 있는 일반적인 형태로서 주계곡의 계 그림 5-2-9. 급경사지역의 계곡임도(사행형)상물매가 한계 종단물매 이내로서 지류를 우회하므로서 노선 설치가 가능한 곳이다. (그림 5-2-11)은 편평한 주계곡을 따라 주임도에서 분기하는 지선(부)임도의 연결형으로서, (그림 5-2-11)의 ㉠형은 소계곡의 계상물매가 주계곡과의 연결부위 물매가 완만한 경우에 설치가능하고, (그림 5-2-11)의 ㉡형은 소계곡의 계상물매가 주계곡과 연결부위 물매가 급한 경우에 흔히 설치할 수 있는 형으로서 주계곡의 상단부에서 배향곡선으로 우회하여 소계곡으로 진입한다. 소계곡 +그림 5-2-9. 급경사지역의 계곡임도(사행형) -![그림 30-3](<../pic/산림과임업기술(임도)_p30_3.png>) +![임도계획_그림5-2-11_편평한주계곡의계곡임도](<../pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png>) -![그림 30-2](<../pic/산림과임업기술(임도)_p30_2.png>) +![임도계획_그림5-2-10_중경사지역의계곡임도](<../pic/임도계획_그림5-2-10_중경사지역의계곡임도.png>) -곡선부 소소소소능능하향물매계계 선 선곡곡계류급경사부분계류주계곡주계곡계류주계곡주계류 ㉠ 편평한 계곡 ㉡ 급경사 계곡 그림 5-2-10. 중경사지역의 계곡임도 그림 5-2-11. 편평한 주계곡의 계곡임도 +그림 5-2-10. 중경사지역의 계곡임도 + +그림 5-2-11. 편평한 주계곡의 계곡임도 ##### 나) 사면임도 사면임도는 계곡임도에서 시작되어 산록부와 산복부에 설치하는 임도로서 노선선정은 하단부로부터 점차적으로 계획하여 진행한다. 동일한 사면에서 부득이 배향곡선(Hairpin Curve)을 설치하여야 할 경우에는 가능한 한 그 수를 최소한으로 줄여야 한다. 왜냐하면 배향곡선을 설치하면 산림생산면적이 감소되어 임도의 이용율을 저하시킬 뿐만 아니라 토사유출을 유발하여 임지훼손의 원인이 되므로 가능한 한 경사도가 40% 이하되는 사면에 설치하되 동일사면에 1개 이상 설치하지 아니하도록 한다. (그림 5-2-12)는 산지의 사면이 발달되고 완경사 지형인 평활한 사면형태에서 흔히 설치할 수 있는 임도망형으로서 노선을 일정한 간격으로 배치하여 서로 연결할 수 있도록 배치한다. (그림 5-2-13)은 사면은 발달되어 있으나 경사도가 급하고 사면장이 긴 지형에 흔히 설치할 수 있는 노선형으로서 사행형으로 되는 임도망형이며 노선의 배치형태를 보면 주임도는 계곡부에서 배향곡선을 이용하여 산복부로 진행하고 부임도는 배향곡선의 가장자리에서 각각 분지하여 설치된다. -![그림 31-2](<../pic/산림과임업기술(임도)_p31_2.png>) +![임도계획_그림5-2-13_급경사긴사면의사행형](<../pic/임도계획_그림5-2-13_급경사긴사면의사행형.png>) -![그림 31-1](<../pic/산림과임업기술(임도)_p31_1.png>) +![임도계획_그림5-2-12_완경사지형의평형노망](<../pic/임도계획_그림5-2-12_완경사지형의평형노망.png>) 지선 능선 능선 지선 간선 간선 계 류 계곡부 계 류 계 류 그림 5-2-12. 완경사지형의 평형노망 그림 5-2-13. 급경사이고 긴 사면에서의 사행형 @@ -199,11 +199,11 @@ R·RD·(1+η)·(1+η′)RC =1,000V 여기서, RD : 실제 임도밀도(m/ha 능선임도는 축조비용이 적고 토사 유출도 적지만 가선집재방법과 같은 상향집재시스템에 의하지 않고는 산림을 개발할 수 없다. 능선임도에서 중력식 집재방법을 이용하는 한가지 방법은 (그림 5-2-14)와 같이 어골형의 형태로서, 능선임도에서 사면의 하향으로 완물매 또는 등고선물매로서 임도를 구축하는 것이다. 만약, 계곡의 기부가 늪(沼)이나 험준한 암석지대로 인하여 접근할 수 없거나 또는 능선에 부락이 위치하고 있을 때는 (그림 5-2-15)와 같은 능선임도를 구축할 필요가 있다. -![그림 31-3](<../pic/산림과임업기술(임도)_p31_3.png>) +![임도계획_그림5-2-14_능선임도의어골형](<../pic/임도계획_그림5-2-14_능선임도의어골형.png>) 간선 능선 -![그림 31-4](<../pic/산림과임업기술(임도)_p31_4.png>) +![임도계획_그림5-2-15_능선임도형](<../pic/임도계획_그림5-2-15_능선임도형.png>) 능선 지선 늪지대 계곡계류계곡계류 그림 5-2-14. 능선임도에서의 어골형 그림 5-2-15. 능선임도형 @@ -211,7 +211,7 @@ R·RD·(1+η)·(1+η′)RC =1,000V 여기서, RD : 실제 임도밀도(m/ha 산정부의 개발은 산정부 주위를 순환하는 노망을 설치하는 것이 적절하게 산림을 개발할 수 있는 방법으로 이용될 수 있는 경우가 많다. 산정부의 사면이 발달된 곳에서 (그림 5-2-16)과 같은 순환임도를 설치하여 하향 또는 가선에 의한 상향집재 방법으로 수확작업의 실행이 가능하며, 특히 상단부 사면이 발달한 반면에 중복 부의 사면경사도가 급한 곳에 흔히 설치할 수 있는 노망형이다. 이때 순환임도의 시점과 종점은 능선부에 있는 안부(鞍部, Saddle)가 가장 적당한 곳이 된다. -![그림 32-1](<../pic/산림과임업기술(임도)_p32_1.png>) +![임도계획_그림5-2-16_산정부순환임도형](<../pic/임도계획_그림5-2-16_산정부순환임도형.png>) 그림 5-2-16. 산정부 순환임도형 @@ -223,11 +223,11 @@ R·RD·(1+η)·(1+η′)RC =1,000V 여기서, RD : 실제 임도밀도(m/ha 동일유역에서 통상적인 노망으로는 임도건설비가 과다하게 소요 되어 경제적인 타당성이 없기 때문에 계곡임도를 개설할 수 없는 경우의 임지에 대하여는 산림에서 생산되는 임목을 상향 수송이 가능하도록 다른 사면에 개설된 임도의 안부를 경유하여 완물매(역물매로 설치됨 : 6%이하)의 임도를 개설하지 않으면 안된다. 이러한 경우에서는 (그림 5-2-18)과 같이 계곡의 발달이 거의 없거나, 늪(沼) 또는 암석 급경사지 등의 원인으로서 경제적으로 임도설치가 곤란한 지역에서는 반대편에서 생산된 임목을 사면임도를 통하여 안부(鞍部 : Saddle)를 넘어 역물매로서 수송하여야 할 필요가 있는 곳에서 흔히 설치될 수 있는 노망형이다. 계류 -![그림 32-3](<../pic/산림과임업기술(임도)_p32_3.png>) +![임도계획_그림5-2-18_반대사면으로부터의임도이용형](<../pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png>) 계곡 능선 급경사암석지형 -![그림 32-2](<../pic/산림과임업기술(임도)_p32_2.png>) +![임도계획_그림5-2-17_계곡분지의순환임도형](<../pic/임도계획_그림5-2-17_계곡분지의순환임도형.png>) 능선 ㉠㉡지선임도안부 계곡 능선수계계간선임도곡수계계곡계류그림 5-2-17. 계곡분지의 순환임도형 그림 5-2-18. 반대사면으로부터의 임도이용형 @@ -236,7 +236,7 @@ R·RD·(1+η)·(1+η′)RC =1,000V 여기서, RD : 실제 임도밀도(m/ha - ① 양각기의 1폭(S)을 임도의 영선(Zero Line)에 대한 수평거리(D)로 하고 등고선간격(1/25,000지형도 : 10m, 1/50,000지형도 : 20m)을 높이(h)로 간주하여 종단물매(G)를 산출한 후 지형도상에서 적정한 노선을 선정하는 노망계획방법이다. - ② (그림5-2-19)에서 수평거리 100m에 대한 높이 p m 가 G % 라고 한다면 D : h = 100 : G 에서 D = 100·h / G 여기서, D : 양각기 1폭에 대한 실거리(m) h : 등고선 간격(m) G : 물매(%) ※도상거리(d) = 실거리(ℓ)÷축척의 분모수 -![그림 33-1](<../pic/산림과임업기술(임도)_p33_1.png>) +![임도계획_그림5-2-19_양각기에의한물매설정](<../pic/임도계획_그림5-2-19_양각기에의한물매설정.png>) G%hP100D 그림5-2-19. 양각기에 의한 물매설정 @@ -271,7 +271,7 @@ G%hP100D 그림5-2-19. 양각기에 의한 물매설정 - ④ 양각기의 폭(s)을 (표 5-2-10)을 참고하여 지형도의 축척에 알맞게 조정한다. 이때 양각기 1개로서 상황에 따라 조정 사용하면 오차의 발생이 크므로 5∼6개의 양각기를 준비하여 사용이 빈번한 물매에 해당되는 것은 그 폭을 고정시켜 사용하면 오차를 줄일 수 있으며 작업진행도 빠르고 편리하다. - ⑤ 시점부와 종점부의 결정은 대단히 중요하므로 기설도로에 대한 절·성토사면형태, 평면선형 등에 대한 현지조건을 검토하고, 시점으로 부터 계획코자 하는 물매로 경유하여야 할 유리점은 통과하고 피하여야 할 불리점은 피하면서 한폭 한폭씩 (그림 5-2-20)과 같이 진행한다. -![그림 35-1](<../pic/산림과임업기술(임도)_p35_1.png>) +![임도계획_그림5-2-20_양각기사용예](<../pic/임도계획_그림5-2-20_양각기사용예.png>) 그림 5-2-20. 양각기 사용(예) @@ -293,7 +293,7 @@ G%hP100D 그림5-2-19. 양각기에 의한 물매설정 | 시 설 | | 44.8 | (20m/ha) | | 계 획 | | 21.1 | (10m/ha) | -![그림 35-2](<../pic/산림과임업기술(임도)_p35_2.png>) +![임도계획_그림5-2-21_임도망계획도](<../pic/임도계획_그림5-2-21_임도망계획도.png>) 그림 5-2-21. 임도망계획도(예:광릉시험림) @@ -426,9 +426,9 @@ TC = ΣSC +ΣRC 여기서, TC : 총비용 ΣSC : 총집재비 ΣRC : 총임 개발지수는 임도의 질적 기준을 나타내는 지표로서 임도배치 효율성의 정도를 표현하며 다음 식으로 나타낸다. I = ASD × FRD / 2,500 여기서, I : 개발지수 ASD : 평균집재거리(m)FRD : 임도밀도(m/ha)이론적으로 균일하게 임도가 배치되었을 때는 ASD × FRD = 2,500이 되므로 개발지수는 1.0이 되고, 이보다 크거나 작을수록 더욱 더 불균일한 상태가 된 것으로 본다. 개발지수는 임도망의 배치상태가 (그림 5-2-23)의 ①, ②와 같이 균일하면 개발지수는 1.0으로서 이용효율성이 높지만, (그림 5-2-23)의 ④, ⑧과 같이 노선이 중첩되면 될수록 이용효율성은 1.001.00그 비율(예, ④=31%, ⑧=50%)만큼 저하하게 된다.1.442.00따라서 임도간격과 밀도가 동일하다고 할지라도 노망의 배치상태에 따라서 그 이용효율성은 크게 달라진다. -![그림 42-1](<../pic/산림과임업기술(임도)_p42_1.png>) +![임도계획_그림5-2-23_노망배치형태별개발지수](<../pic/임도계획_그림5-2-23_노망배치형태별개발지수.png>) -I=1.00 ①I=1.00 ②I=1.34 ③I=1.44 ④I=1.18 ⑤I=1.22 ⑥I=1.20 ⑦I=2.00 ⑧I=0.98 ⑨그림 5-2-23. 노망배치 형태별 개발지수 +그림 5-2-23. 노망배치 형태별 개발지수 #### 3) 경제성 분석 @@ -438,9 +438,9 @@ I=1.00 ①I=1.00 ②I=1.34 ③I=1.44 ④I=1.18 ⑤I=1.22 ⑥I=1.20 ⑦I=2.00 ⑧ 볼 수 있으므로 운반량이 많을수록 ha당 임도비는 감소한다. 반면에 임도사용자비용은 시간이 경과함에 따라 점점 누증(累增)되는 변동비(Variable Cost)이기 때문에 ha당 임도사용자비용이 증가하게 된다. 따라서 임도운반비는 ha당 임도비와 생산량에 따라서 변화되며 어느 적정한계를 넘거나 모자라면 불리해진다. ha당 운반량이 많은 임도일수록 임도비보다 임도사용자비용이 임도운반비 중에 차지하는 비중이 높으므로 ha당 운반량이 많은 임도일수록 설계수준을 더 높게 책정할 수 있는 이론적 근거를 제시하여 준다. -![그림 43-1](<../pic/산림과임업기술(임도)_p43_1.png>) +![임도계획_그림5-2-24_임도운반비의추정도](<../pic/임도계획_그림5-2-24_임도운반비의추정도.png>) -임도운반비임 임도사용자임도사용자 ha도 비용 비용당 운 비 반용임도비비임도비년 운반량 그림 5-2-24. 임도운반비의 추정도 +그림 5-2-24. 임도운반비의 추정도 ##### 가) 기본 자금 공식 diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md index 7df69077..43424eed 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md @@ -55,9 +55,9 @@ | | | | (Undrained)액성한계WL≦28 | U | | 주) 제3문자는 도로 및 비행장에 사용되는 기호 | | | | | -![그림 50-2](<../pic/산림과임업기술(임도)_p50_2.png>) +![노선측량_그림5-2-26_통일분류법을위한소성도](<../pic/노선측량_그림5-2-26_통일분류법을위한소성도.png>) -![그림 50-1](<../pic/산림과임업기술(임도)_p50_1.png>) +![노선측량_그림5-2-25_삼각좌표에따른흙의분류도](<../pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png>) 그림5-2-25. 삼각좌표에 따른 흙의 분류도 @@ -91,7 +91,7 @@ - ① 당해 노선을 임도망계획서를 참고하여 건설비, 유지비가 최소화될 수 있도록 그 지역에서 가장 적정한 지형도에 노선을 도시하여 답사도를 작성한다. - ② 이 답사도를 이용하여 현장을 답사하고 현장여건과 도상의 추정상태가 동일한가? 또는 더 유리한 노선의 대안이 없는지를 조사 비교한다. - ③ 두 노선의 우열을 검토하여 유리한 노선을 선정하되 우열이 곤란한 지역은 두 노선을 모두 선정하여 도시한다. -- ④ 측정방법 ㉮ 거리측정 : 목측, 보측 또는 테이프 등으로 약측 ㉯ 방향측정 : 핸드컴퍼스 ㉰ 고저차 : 핸드레벨, 경사측정기, 기압계 등 ㉱ 기타자재 : 쌍안경, 포올(pole) 등, 특히 목측으로 측정할 경우에는 시환(視幻)에 주의하여야 한다. ※ 시환이란? • 눈 앞의 직선은 길게 보이고, 먼거리에 있는 것은 짧게 보임. • 비탈진 지반에 서서 높은 곳을 보면 45。는 75。로, 약 60。는 거의 수직처럼, 1할 5부는 1할의 기울기로 보임. 특히 높은 곳에서 비탈진 아래로 보면 더 심함 • 덤불이 무성한 지역은 공사하기가 곤란하게, 반대로 고저기복이 심하지 않는 곳이나 기울기가 완만한 곳은 공사하기가 쉽게 보임 +- ④ 측정방법 ㉮ 거리측정 : 목측, 보측 또는 테이프 등으로 약측 ㉯ 방향측정 : 핸드컴퍼스 ㉰ 고저차 : 핸드레벨, 경사측정기, 기압계 등 ㉱ 기타자재 : 쌍안경, 포올(pole) 등, 특히 목측으로 측정할 경우에는 시환(視幻)에 주의하여야 한다. ※ 시환이란? • 눈 앞의 직선은 길게 보이고, 먼거리에 있는 것은 짧게 보임. • 비탈진 지반에 서서 높은 곳을 보면 45°는 75°로, 약 60°는 거의 수직처럼, 1할 5부는 1할의 기울기로 보임. 특히 높은 곳에서 비탈진 아래로 보면 더 심함 • 덤불이 무성한 지역은 공사하기가 곤란하게, 반대로 고저기복이 심하지 않는 곳이나 기울기가 완만한 곳은 공사하기가 쉽게 보임 - ⑤ 산정, 계곡, 지질, 가옥, 토취장, 토사장, 인부의 공급, 기타 경제적인 문제 등을 조사한다. #### 2) 주요지점의 결정 ##### 가) 시·종점의 결정 @@ -120,13 +120,13 @@ #### 1) 중심선법과 영선법의 차이점 - ① (그림 5-2-27)과 같이 노폭의 1/2이 되는 지점을 중심점(中心點 : Center Point)이라하고 이 점을 연결한 노선의 종축을 중심선(中心線 : Center Line)이라 한다. 경사지에 설치하는 측점별로 임도에서 노면의 시공면(Road Plane)과 산지의 경사면이 만나는 점을 영점(零點 :Zero Point)이라하고 이 점을 연결한 노선의 종축을 영선(零線 : Zero Line)이라 한다. -![그림 54-1](<../pic/산림과임업기술(임도)_p54_1.png>) +![노선측량_그림5-2-27_중심선과영선의위치비교](<../pic/노선측량_그림5-2-27_중심선과영선의위치비교.png>) -중심선 10111213 영선 1415 영선 1617 종단면도 평면도 횡단면도 그림 5-2-27. 중심선과 영선의 위치 비교 +그림 5-2-27. 중심선과 영선의 위치 비교 - ② 영선은 (그림 5-2-28)에서 보는 바와 같이 절토작업과 성토작업의 경계선이 되기도 한다. -![그림 54-2](<../pic/산림과임업기술(임도)_p54_2.png>) +![노선측량_그림5-2-28_경사지임도의영선과기면](<../pic/노선측량_그림5-2-28_경사지임도의영선과기면.png>) 영선절토노면기면(시공기면)성토지반선 그림 5-2-28. 경사지 임도의 영선과 기면 @@ -141,7 +141,7 @@ - ① 노선의 방향이 바뀌는 점{交點 : Intersection Point(IP)}에는 교점말뚝을 박고 시점말뚝을 0으로 하여 교점의 일련번호를 기입한다. - ② 교점말뚝 1의 중심점에 측각기구(트랫싯,컴퍼스 등)를 설치하여 시점(BP)를 시준한 후 교 점 2를 반복 시준하여 교각(IA)를 구한다. 교점은 중심선측량에서 매우 중요한 측점이며 시공측량이나 공사 완성 후 노선의 유지관리 및 개량시에도 필요하므로 교점말뚝은 잘 보존하여야 한다. - ③ 노선의 시점을 기준으로 20m마다 측점말뚝(Station Peg)을 박은 후 시점말뚝으로부터 측 점번호를 기입한다. 지형상 종·횡단의 변화가 심한 지점, 구조물설치 지점, 곡선부의 주요점 등에는 보조말뚝(Reference Peg)을 설치하여 측점번호를 부여한다. 이때 측점간의 번호는 20m이내에서 조정된다. -- ④ 교점간의 내각이 155。이내일 경우에는 평면곡선을 삽입하여야 하며 곡선부에서도 연속적으로 곡선을 따라 20m 간격으로 측점말뚝을 박고, 곡선의 시곡점(BC), 중곡점(MC), 종곡점(EC)에도 말뚝을 박아 보조측점을 부여한다. +- ④ 교점간의 내각이 155°이내일 경우에는 평면곡선을 삽입하여야 하며 곡선부에서도 연속적으로 곡선을 따라 20m 간격으로 측점말뚝을 박고, 곡선의 시곡점(BC), 중곡점(MC), 종곡점(EC)에도 말뚝을 박아 보조측점을 부여한다. - ⑤ 때로는 주요말뚝, 교점말뚝의 좌우에 필요할 경우 보호말뚝을 설치하기도 한다. - ⑥ 측량기구 및 방법은 제 4장 2 -(3), (5) 참조. ##### 나) 종단측량 @@ -165,13 +165,13 @@ - ① 측량자 : 경사측정기(Clinometer), 지지봉(Clinometer rod), 컴퍼스 또는 핸드컴퍼스, 고도계 - ② 보조자 : 30m 줄자(Tape), 10m 토우로프(Tow Rope), 표지테이프(Marking Tape), 표적판(Clinometer Target), 유성펜 -![그림 57-1](<../pic/산림과임업기술(임도)_p57_1.png>) +![노선측량_그림5-2-29_표적판과지지봉](<../pic/노선측량_그림5-2-29_표적판과지지봉.png>) ##### 다) 기구제작방법 - ① 표적판 : (그림 5-2-29)의 1과 같이 크기는 50∼70cm×20∼30cm의 판을 3cm×3cm×120∼150cm의 각재에 부착하고, 조도가 낮은 임내에서도 시준이 가능하도록 야광 또는 발광 페인트로서 표면에는 백색바탕에 적색띠를 세로로 3∼4선을 그리고, 뒷면은 동계 적설시에도 사용할 수 있도록 황색바탕으로 한다. - ② 지지봉 : (그림 5-2-29)의 2와 같이 크기는 3cm×3cm×120∼150cm의 각재 또는 원형봉으로 하고, 지지봉의 길이는 상단에 경사측정기를 설치한 상태의 시준 선이 표적판의 상단과 일치되도록 {즉, 지지봉길이+경 그림 5-2-29. 표적판과 지지봉사측정기의 시준선높이(약 27mm)=표적판의 상단높이}한다. -![그림 57-2](<../pic/산림과임업기술(임도)_p57_2.png>) +![노선측량_그림5-2-30_토우로프](<../pic/노선측량_그림5-2-30_토우로프.png>) - ③ 토우로프 : (그림 5-2-30)과 같이 가벼운 로프의 총 길이를 10m가 되게 양쪽 끝에 고리를 만들어 한쪽고리는 말뚝에 걸고 다른 한쪽 고리를 잡아 10m길이를 측정하도록 만든다. 그림 5-2-30. 토우로프 ##### 라) 측량방법 @@ -201,7 +201,7 @@ - ○ 제 2단계 작업 - ① 제 1단계 작업에서 현지조건에 잘 부합되지 않거나 이론적인 계획선에 편차가 너무 클 경우에는 불부합구간 끝지점에서 되돌아 오거나 또는 조정하여야 할 구간을 (그림 5-2-31)과 같이 제 1단계의 작업과 같은 방법으로 되풀이 한다. -![그림 59-1](<../pic/산림과임업기술(임도)_p59_1.png>) +![노선측량_그림5-2-31_개산물매와수정](<../pic/노선측량_그림5-2-31_개산물매와수정.png>) -4%+9%개산물매물매수정 h2h3 +4%h12020L1(200m)L2(220m)L3(130m)그림 5-2-31. 제1단계작업시 槪算물매와 修正 @@ -227,13 +227,13 @@ ##### 바) 굴곡부 등의 측량 - ① 영선이 계곡부나 능선부를 횡단할 경우와 지형조건이 불규칙하거나 불량할 경우에는 중심선측량법에 의하여 중심선을 표지한다. 이때 영선과 중심선 사이의 간격이 너무 떨어져 있을 경우에는 중심선물매까지 위치와 계획된 종단물매의 차이를 구하여 차이가 클 경우에는 (그림 5-2-32)와 같이 영선을 조정한다. -![그림 60-1](<../pic/산림과임업기술(임도)_p60_1.png>) +![노선측량_그림5-2-32_굴곡부의영선과중심선](<../pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png>) 잘못됨 2535 축(중심선)계곡 2030등고선영선 잘못됨 그림 5-2-32. 굴곡부에서 영선과 중심선의 작업방법 - ② 일반적으로 능선부나 계곡부를 횡단할 경우에는 (그림 5-2-33)과 같이 종단물매를 낮추거나 높혔다가 정점(頂點)이나 곡점(曲點)이 지나면 반대로 높히거나 낮춘다. 이때 물매의 편차폭도 2∼3%이다. -![그림 61-2](<../pic/산림과임업기술(임도)_p61_2.png>) +![노선측량_그림5-2-33_능선부와계곡부의종단물매](<../pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png>) 그림 5-2-33. 능선부와 계곡부에서 종단물매 설치방법 @@ -249,23 +249,23 @@ 각방법은 다음과 같다. -- ① (그림 5-2-34)의 점 A에 트랜싯을 설치하여 분도원의 0。와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 자침을 늦추어 콤파스의 0。를 자침끝에 맞추어 정지시키고 하부고정나사를 잠근다. +- ① (그림 5-2-34)의 점 A에 트랜싯을 설치하여 분도원의 0°와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 자침을 늦추어 콤파스의 0°를 자침끝에 맞추어 정지시키고 하부고정나사를 잠근다. -![그림 61-1](<../pic/산림과임업기술(임도)_p61_1.png>) +![노선측량_그림5-2-34_방위각법트래버어스측량](<../pic/노선측량_그림5-2-34_방위각법트래버어스측량.png>) 그림 5-2-34. 방위각법에 의한 트래버어스 측량 -- ② 상부고정나사를 풀어 기계를 시계방향으로 수평회전시켜 점 B를 시준하고 상부고정나사를 잠그고, 미동나사로 정준하여 AB의 방위각(67。)을 읽는다. -- ③ ②에서 측정한 방위각(67。)을 그대로 둔 채로 하부고정나사를 풀고, 망원경을 반위하여 점 B에 옮겨 설치한다. -- ④ 점 A를 시준하여 하부고정나사를 잠그고 망원경을 정위하여 점 B'방향으로 향하게 한 후 상부고정나사를 풀고, 기계를 시계방향으로 회전하여 점 C를 시준한 후 상부고정나사를 잠그고 BC의 방위각(90。)을 읽는다. +- ② 상부고정나사를 풀어 기계를 시계방향으로 수평회전시켜 점 B를 시준하고 상부고정나사를 잠그고, 미동나사로 정준하여 AB의 방위각(67°)을 읽는다. +- ③ ②에서 측정한 방위각(67°)을 그대로 둔 채로 하부고정나사를 풀고, 망원경을 반위하여 점 B에 옮겨 설치한다. +- ④ 점 A를 시준하여 하부고정나사를 잠그고 망원경을 정위하여 점 B'방향으로 향하게 한 후 상부고정나사를 풀고, 기계를 시계방향으로 회전하여 점 C를 시준한 후 상부고정나사를 잠그고 BC의 방위각(90°)을 읽는다. - ⑤ 이와 같이 계속하여 측정하고 출발점 A에 돌아와 AB의 방위각을 재측정하여 최초의 측정치와 동일하면 정상이다. - ⑥ 만일 일치하지 않으면 오차가 발생한 것이므로 조정하거나 재측하여야 한다. 방위각법에의한 트래버어스측량의 야장은 (표 5-2-18)과 같다. 표 5-2-18. 방위각법 트래버어스측량의 야장기입법(예) | 측 선 | 거 리 | 방 위 각 | 방 위 | 비 고 | |---|---|---|---|---| -| A B | 51.80m | 60。00' | | | -| B C | 41.55m | 90。00' | | | -| C D | 40.80m | 130。00' | | | +| A B | 51.80m | 60°00' | | | +| B C | 41.55m | 90°00' | | | +| C D | 40.80m | 130°00' | | | - ○ 편각법에 의한 트래버어스측량의 측각편각(偏角 : Deflection Angle)이란 그 선의 연장선과 인접되는 선이 이루는 각을 말하며, 시 @@ -274,25 +274,25 @@ | 측 선 | 거 리 | 편 각 | | 비 고 | |---|---|---|---|---| | | | + | - | | -| A B B C C D | 54.35m 52.12m 48.40m | 90。17' 60。10' | 35。10' | | +| A B B C C D | 54.35m 52.12m 48.40m | 90°17' 60°10' | 35°10' | | -![그림 62-1](<../pic/산림과임업기술(임도)_p62_1.png>) +![노선측량_그림5-2-35_편각법트래버어스측량](<../pic/노선측량_그림5-2-35_편각법트래버어스측량.png>) -BB60。10′60。10′91。17′91。17′48.40m48.40m52.12m52.12mDD54.35m54.35mCCAA35。10′35。10′FFEE 그림 5-2-35. 편각법에 의한 트래버어스 측량 +그림 5-2-35. 편각법에 의한 트래버어스 측량 -- ① (그림 5-2-35)에서 점 A에 트랜싯을 설치하고, 분도원 0。와 버어니어 0을 일치시켜 상부 고정나사를 잠근 후 망원경을 반위하여 점 F를 후시하고 하부고정나사를 잠근다. +- ① (그림 5-2-35)에서 점 A에 트랜싯을 설치하고, 분도원 0°와 버어니어 0을 일치시켜 상부 고정나사를 잠근 후 망원경을 반위하여 점 F를 후시하고 하부고정나사를 잠근다. - ② 망원경을 정위로 하고, 상부고정나사를 푼 후 기계를 수평으로 회전시켜 점 B를 전시하여 상부고정나사를 잠그고 각을 읽는다. -- ③ 이렇게 계속하며, 폐합트래버어스에서는 그 대수합이 360。가 되어야 한다. 편각법에 의한트래버어스측량에 있어서 첫 측점의 방위각은 실측하여 두는 것이 좋다(야장정리시에 방위각법으로 환산가능하기 때문임). +- ③ 이렇게 계속하며, 폐합트래버어스에서는 그 대수합이 360°가 되어야 한다. 편각법에 의한트래버어스측량에 있어서 첫 측점의 방위각은 실측하여 두는 것이 좋다(야장정리시에 방위각법으로 환산가능하기 때문임). - ○ 내각법에 의한 트래버어스측량의 측각 서로 접하는 두 측선이 이루는 사이각인 내각(內角 : Interior Angle)을 측정하는 방법으로 측 각방법은 다음과 같다. -- ① (그림 5-2-36)에서 점 A에 트랜싯을 설치하고 분도원의 0。와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 점 D를 후시하여 하부고정나사를 잠근다. +- ① (그림 5-2-36)에서 점 A에 트랜싯을 설치하고 분도원의 0°와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 점 D를 후시하여 하부고정나사를 잠근다. - ② 상부고정나사를 풀어 점 B를 전시하여 AB의 방위각 θ와 점 A의 내각(∠BAD)을 측정한다. - ③ 점 B에 기계를 이동 설치하고 ①과 같은 방법으로 한다. 점 A를 후시하여 하부고정나사를 잠근 후, 상부고정나사를 풀고 점 C를 전시하고 점 B의 내각을 측정한다. -- ④ 이와 같이 계속 진행하며 n각형의 내각의 합은 180。×(n-2)를 만족시켜야 한다. +- ④ 이와 같이 계속 진행하며 n각형의 내각의 합은 180°×(n-2)를 만족시켜야 한다. -![그림 63-1](<../pic/산림과임업기술(임도)_p63_1.png>) +![노선측량_그림5-2-36_내각법트래버어스측량](<../pic/노선측량_그림5-2-36_내각법트래버어스측량.png>) BN θACD 그림 5-2-36. 내각법에 의한 트래버어스측량 @@ -307,75 +307,79 @@ BN θACD 그림 5-2-36. 내각법에 의한 트래버어스측량 - ④ 특별한 경우를 제외하고는 측정오차가 각 점의 각에서 평균되게 발생한다고 가정하여 각 점의 각에 가감한다. - ○ 트래버어스 측정치의 충족조건 - - 폐합트래버어스 측정치의 충족조건 변수(邊數)를 n, 측정각을 a1, a2, a3, …, 〔a〕= a1+a2+a3+ … 라 하면, 측정오차 Δa 는 다음식에 의하여 산출한다. -- ① 내각 측정인 경우 : Δa = 180。(n-2) -〔a〕 -- ② 외각 측정인 경우 : Δa = 180。(n+2) -〔a〕 -- ③ 편각 측정인 경우 : Δa = 360。- 〔a〕 +- ① 내각 측정인 경우 : Δa = 180°(n-2) -〔a〕 +- ② 외각 측정인 경우 : Δa = 180°(n+2) -〔a〕 +- ③ 편각 측정인 경우 : Δa = 360°- 〔a〕 - - 결합트래버어스 측정치의 충족조건 -- ① (그림 5-2-37)의 (a) 인 경우: Δa = (Wa-Wb) +〔a〕- 180。(n+1) -- ② (그림 5-2-37)의 (b), (c) 인 경우: Δa = (Wa-Wb) +〔a〕- 180。(n-1) -- ③ (그림 5-2-37)의 (d) 인 경우: Δa = (Wa-Wb) +〔a〕- 180。(n-3)여기서, a1, a2, a3,…, an : 교각의 측정치, Wa, Wb : AL, BM의 방위각 +- ① (그림 5-2-37)의 (a) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n+1) +- ② (그림 5-2-37)의 (b), (c) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n-1) +- ③ (그림 5-2-37)의 (d) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n-3)여기서, a1, a2, a3,…, an : 교각의 측정치, Wa, Wb : AL, BM의 방위각 -![그림 64-1](<../pic/산림과임업기술(임도)_p64_1.png>) +![노선측량_그림5-2-37_트래버어스측정치조건](<../pic/노선측량_그림5-2-37_트래버어스측정치조건.png>) -NNMMNNWbWbWaa2La2anana1an-1an-1BBA(a)(b)AWaNMNNMLNWaLa2anana1an-1an-1BBWbAWbA(d)(c)WaWa 그림 5-2-37. 트래버어스 측정치의 조건 +그림 5-2-37. 트래버어스 측정치의 조건 ##### 다) 측정각들의 상호 환산법 - ○ 교각에서 방위각을 구하는 계산 교각의 측정은 진행방향의 좌측각을 측각하는 경우와 우측각을 측각하는 경우의 두 가지가 -있다. 좌측각의 경우{그림 5-2-38의(a)참조)는 (+)}로, 우측각의 경우{그림 5-2-38의 (b) 참조는(-)}로 나타내며, 교각에서 방위각은 다음식에 의하여 산출할 수 있다. 측선의 방위각 = 앞 측선의 방위각 + 180。± 교각(여기서, 구해진 각이 360。보다 크면 그 각에서 360。를 빼고, 구해진 각이 0。보다 작으면 360。 +있다. 좌측각의 경우{그림 5-2-38의(a)참조)는 (+)}로, 우측각의 경우{그림 5-2-38의 (b) 참조는(-)}로 나타내며, 교각에서 방위각은 다음식에 의하여 산출할 수 있다. 측선의 방위각 = 앞 측선의 방위각 + 180°± 교각(여기서, 구해진 각이 360°보다 크면 그 각에서 360°를 빼고, 구해진 각이 0°보다 작으면 360° 를 더한다.) -![그림 65-1](<../pic/산림과임업기술(임도)_p65_1.png>) +![노선측량_그림5-2-38_트래버어스방위각산출_01](<../pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png>) -![그림 65-2](<../pic/산림과임업기술(임도)_p65_2.png>) +![노선측량_그림5-2-38_트래버어스방위각산출_02](<../pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png>) (a) 좌측각의 경우(b)우측각의 경우 그림 5-2-38. 트래버어스 측각에서 방위각의 산출 - ○ 방위각, 편각, 내각에서 방위를 구하는 계산 - - 방위각에서 방위를 구하는 계산(그림 5-2-39)과 같이 방위각을 측정하여 방위를 산출하는 방법은 (표 5-2-20)과 같다. -![그림 65-3](<../pic/산림과임업기술(임도)_p65_3.png>) +![노선측량_그림5-2-39_방위각과방위의관계](<../pic/노선측량_그림5-2-39_방위각과방위의관계.png>) NADEWBCS 그림 5-2-39. 방위각과 방위의 관계 표 5-2-20. 방위각에서 방위를 산출하는 방법 | 방 위 각 | 방 위 | |---|---| -| 0∼ 90。 | N (방위각) E | -| 90∼180。 | S (180。-방위각) E | -| 180∼270。 | S (방위각-180。)W | -| 270∼360。 | N (360。-방위각) W | +| 0∼ 90° | N (방위각) E | +| 90∼180° | S (180°-방위각) E | +| 180∼270° | S (방위각-180°)W | +| 270∼360° | N (360°-방위각) W | - - 편각에서 방위를 구하는 계산(그림 5-2-40)과 같이 편각에서 방위를 구할 때에는 먼저 방위각을 구한 후, 방위각에서 방위를 구한다. - ① 최초선의 방위각은 그선의 방위각이 된다. - ② 임의의 측선의 방위각 = 앞 측선의 방위각 + 앞 측선과 이루는 편각(방위각을 구하려는 점의 편각)즉, β2 = β1 + α2 - ③ 그러나, 측점이 최초의 점에 있지 않고, 그 측선이 반향(反向)하여 (-)인 음각(負角)이 생기는 경우(그림 5-2-40의 점 C)에는 측선의 방위각 = 앞 측선의 방위각 - 그 측선과 이루는 편각 즉 β3 = β2 -α3 -![그림 66-1](<../pic/산림과임업기술(임도)_p66_1.png>) +![노선측량_그림5-2-40_편각과방위의관계](<../pic/노선측량_그림5-2-40_편각과방위의관계.png>) -N β2-α3=β3 α2BNN β3N β2 β3DC α3 β1 α4 α1 β4A 그림 5-2-40. 편각과 방위의 관계 +그림 5-2-40. 편각과 방위의 관계 - - 내각에서 방위를 구하는 계산(그림 5-2-41)과 같이 편각에서 방위를 구할 때에도 먼저 방위각을 구한 후, 방위각에서 방위를 구한다. - ① 최초선의 방위각은 그대로 그선의 방위각이 된다. -- ② 임의의 측선의 방위각 = 앞 측선의 방위각 + 180。- 그 측선과 이루는 내각 단, 반대방향으로 계산할 경우에는 (+), (-)를 바꾸어 계산한다. -- ③ ②의 식에서 계산한 각이 360。이상일때는 그 각에서 360。를 뺀다. +- ② 임의의 측선의 방위각 = 앞 측선의 방위각 + 180°- 그 측선과 이루는 내각 단, 반대방향으로 계산할 경우에는 (+), (-)를 바꾸어 계산한다. +- ③ ②의 식에서 계산한 각이 360°이상일때는 그 각에서 360°를 뺀다. -![그림 66-2](<../pic/산림과임업기술(임도)_p66_2.png>) +![노선측량_그림5-2-41_내각과방위의관계](<../pic/노선측량_그림5-2-41_내각과방위의관계.png>) -N β1 β1+180。-B=β2 β2+180。-C=β3NBN β2 β3CDN β1A β3+180。-D=β4 그림 5-2-41. 내각과 방위의 관계 이상에서 설명한 방법에 의해 방위각, 편각, 내각에서 방위를 구하는 계산의 예를 살펴보면(표 5-2-21)과 같다. 표 5-2-21. 방위각, 편각, 내각에서 방위를 구하는 계산의 예 +그림 5-2-41. 내각과 방위의 관계 + +이상에서 설명한 방법에 의해 방위각, 편각, 내각에서 방위를 구하는 계산의 예를 살펴보면 표 5-2-21과 같다. + +표 5-2-21. 방위각, 편각, 내각에서 방위를 구하는 계산의 예 | 측점 | 내 각 | 편 각 | 방위각 | 방 위 | 비 고 | |---|---|---|---|---|---| -| 1 2 3 4 5 6 계 | 95。58' 134。55' 93。46' 148。38' 111。31' 135。12' 720。00' | 84。2' 45。5' 86。14' 31。22' 68。29' 44。48' 360。00' | 29。12' 74。17' 160。31' 191。53' 260。22' 305。10' | N 29。12'E N 74。17'E S 19。29'E S 11。53'W S 80。22'W N 54。50'W | ○NO.1 에서 NO.2의 방위각 = 29。12' ○내각 =우측정 ○도내각 = 180。(n - 2) ○도편각 = 360。 | +| 1 2 3 4 5 6 계 | 95°58' 134°55' 93°46' 148°38' 111°31' 135°12' 720°00' | 84°2' 45°5' 86°14' 31°22' 68°29' 44°48' 360°00' | 29°12' 74°17' 160°31' 191°53' 260°22' 305°10' | N 29°12'E N 74°17'E S 19°29'E S 11°53'W S 80°22'W N 54°50'W | ○NO.1 에서 NO.2의 방위각 = 29°12' ○내각 =우측정 ○도내각 = 180°(n - 2) ○도편각 = 360° | -(계산 예) 측점 1. 내각 → 편각 : 180。- 95。58' = 84。2'내각 → 방위각 : 29。12'편 각 → 방위각 : 29。12'방위각 → 방위 : N 29。12'E 측점 2. 내각 → 편각 : 180。-134。55' = 45。5'내각 → 방위각 : 29。12'+180。-34。55' = 74。17'편 각 → 방위각 : 29。12'+45。5' = 74。17'방위각 → 방위 : N 74。17'E 측점 3. 내각 → 편각 : 180。-93。46' = 86。14'내각 → 방위각 : 74。17'+180。-93。46' = 160。31'편 각 → 방위각 : 74。17'+86。14' = 160。31'방위각 → 방위 : 180。-160。31' = 19。29', S19。29'E 측점 4. 내각 → 편각 : 180-。148。38' = 31。22'내각 → 방위각 : 160。31'+180-。148。38' = 191。53'편 각 → 방위각 : 160。31'+31。22' = 191。53'방위각 → 방위 : 191。53'-180。= 11。53', S11。53'W 측점 5. 내각 → 편각 : 180。-111。31' = 68。29'내각 → 방위각 : 191。53'+180。-111。31' = 260。22'편 각 → 방위각 : 191。53'+68。29' = 260。22'방위각 → 방위 : 260。22'-180。= 80。22', S80。22'W 측점 6. 내각 → 편각 : 180。-135。12' = 44。48'내각 → 방위각 : 260。22'+180。-135。12' = 305。10'편 각 → 방위각 : 260。22'+44。48' = 305。10'방위각 → 방위 : 360。-305。10' = 54。50', N54。50'W +(계산 예) 측점 1. 내각 → 편각 : 180°- 95°58' = 84°2'내각 → 방위각 : 29°12'편 각 → 방위각 : 29°12'방위각 → 방위 : N 29°12'E 측점 2. 내각 → 편각 : 180°-134°55' = 45°5'내각 → 방위각 : 29°12'+180°-34°55' = 74°17'편 각 → 방위각 : 29°12'+45°5' = 74°17'방위각 → 방위 : N 74°17'E 측점 3. 내각 → 편각 : 180°-93°46' = 86°14'내각 → 방위각 : 74°17'+180°-93°46' = 160°31'편 각 → 방위각 : 74°17'+86°14' = 160°31'방위각 → 방위 : 180°-160°31' = 19°29', S19°29'E 측점 4. 내각 → 편각 : 180-°148°38' = 31°22'내각 → 방위각 : 160°31'+180-°148°38' = 191°53'편 각 → 방위각 : 160°31'+31°22' = 191°53'방위각 → 방위 : 191°53'-180°= 11°53', S11°53'W 측점 5. 내각 → 편각 : 180°-111°31' = 68°29'내각 → 방위각 : 191°53'+180°-111°31' = 260°22'편 각 → 방위각 : 191°53'+68°29' = 260°22'방위각 → 방위 : 260°22'-180°= 80°22', S80°22'W 측점 6. 내각 → 편각 : 180°-135°12' = 44°48'내각 → 방위각 : 260°22'+180°-135°12' = 305°10'편 각 → 방위각 : 260°22'+44°48' = 305°10'방위각 → 방위 : 360°-305°10' = 54°50', N54°50'W ##### 라) 위거 및 경거의 계산 N - ○ 트래버어스측량에서 위거 및 경거의 이용 -![그림 68-1](<../pic/산림과임업기술(임도)_p68_1.png>) +![노선측량_그림5-2-42_위거와경거의관계](<../pic/노선측량_그림5-2-42_위거와경거의관계.png>) (그림 5-2-42)에서 XY를 직교축이라고 가정할 때,bBB'종축의 방향(NS)을 자오선(子午線), 횡축의 방향 LABC'(EW)을 위선(緯線)이라 하면 θ는 방위각이다. 이때 C θ측선 AB가 NS축에 비치는 정사영거리 Ab를 AB의 aA'ADAB 위거(緯距 : Latitude) EW축에 비치는 정사영거리 WEAa를 AB의 경거(經距 : Departure), 측선의 중점에서 SNS선에 직각으로 그은 선 CC'의 길이를 자오선거(子 그림 5-2-42. 위거와 경거의 관계 午線距 : Meridian Distance)라 한다. 트래버어스측량에 있어서 위거 및 경거는 다음의 경우에 이용된다. @@ -386,10 +390,10 @@ N | 방위각(θ) | 방 위 | 위 거 | 경 거 | 비 고 | |---|---|---|---|---| -| 0。~ 90。 | N θE | + | + | | -| 90。~180。 | S θE | - | + | | -| 180。~270。 | S θW | - | - | | -| 270。~360。 | N θW | + | - | | +| 0°~ 90° | N θE | + | + | | +| 90°~180° | S θE | - | + | | +| 180°~270° | S θW | - | - | | +| 270°~360° | N θW | + | - | | ○삼각함수에 의한 경위거의 계산 위거 및 경거는 삼각함수의 진수를 사용하여 다음식에 의하여 계산할 수 있다. @@ -400,9 +404,9 @@ N | 측 선 | 거 리 (m) | 방 위 각 | cosθ | sinθ | 위 거 | | | 경 거 | | |---|---|---|---|---|---|---|---|---|---| | | | | | | N(+) | S(-) | | E(+) | W(-) | -| A B B C C D D E | 67.30 79.90 63.37 93.60 | 23。55′40″ 313。10′30″ 16。39′10″ 115。35′50″ | 0.91406 0.68423 0.95806 0.43204 | 0.40558 0.72927 0.28647 0.90185 | 61.52 54.67 60.71 | 40.44 | | 27.30 18.15 84.41 | 58.28 | +| A B B C C D D E | 67.30 79.90 63.37 93.60 | 23°55′40″ 313°10′30″ 16°39′10″ 115°35′50″ | 0.91406 0.68423 0.95806 0.43204 | 0.40558 0.72927 0.28647 0.90185 | 61.52 54.67 60.71 | 40.44 | | 27.30 18.15 84.41 | 58.28 | -(예제) 조림예정지를 트랜싯의 트래버어스에 의하여 측량한 결과, 측선 AB의 거리 67.3m, 방위각 θ= 23。5′40″였다. 삼각함수에 의한 위거 및 경거를 구하여라. (풀이) cos 23。5′40″= 0.91406, sin 23。5′40″= 0.40558 이므로위거 67.30m × 0.91406 = 61.52m 경거 67.30m × 0.40558 = 27.30m +(예제) 조림예정지를 트랜싯의 트래버어스에 의하여 측량한 결과, 측선 AB의 거리 67.3m, 방위각 θ= 23°5′40″였다. 삼각함수에 의한 위거 및 경거를 구하여라. (풀이) cos 23°5′40″= 0.91406, sin 23°5′40″= 0.40558 이므로위거 67.30m × 0.91406 = 61.52m 경거 67.30m × 0.40558 = 27.30m - - 대수에 의한 경위거의 계산 측선의 길이를 S, 방위각을 θ라 하면, 위거는 S·cosθ, 경거는 S·sinθ이므로 양변에 대수(對數 : log)를 취하면 다음과 같은 식이 성립한다. - ① 위거의 대수 =log S + log cosθ @@ -411,17 +415,17 @@ N | 측선 | 방위각 | 거리 (m) | 대 수 | | | | | 위 거 | | 경 거 | | |---|---|---|---|---|---|---|---|---|---|---|---| | | | | 측 선 | cos θ | sin θ | 위 거 | 경 거 | N(+) | S(-) | (+) | (-) | -| AB BC CD DE | 23。55′40" 313。10′30" 16。39′10" 115。35′50" | 67.30 79.90 63.37 93.60 | 1.828015 1.902547 1.801884 1.971276 | 9.960974 9.835201 9.981399 9.635526 | 9.608082 9.862886 9.457232 9.955136 | 1.788989 1.739748 1.783283 1.606802 | 1.436097 1.765334 1.259116 1.926112 | 61.52 54.67 60.71 | 40.44 | 27.30 18.15 84.41 | 58.28 | +| AB BC CD DE | 23°55′40" 313°10′30" 16°39′10" 115°35′50" | 67.30 79.90 63.37 93.60 | 1.828015 1.902547 1.801884 1.971276 | 9.960974 9.835201 9.981399 9.635526 | 9.608082 9.862886 9.457232 9.955136 | 1.788989 1.739748 1.783283 1.606802 | 1.436097 1.765334 1.259116 1.926112 | 61.52 54.67 60.71 | 40.44 | 27.30 18.15 84.41 | 58.28 | | 주) sin 및cos의값은항상1 보다적으므로그대수는(-)이다. 따라서 계산의 편이 상대 수표는sin 및cos의대수값에10 을 | | | | | | | | | | | | | 더한값을이용하고, 최종계산시에10을빼게된다. | | | | | | | | | | | | -| 즉, 위표에서측선AB의방위각은23。5′40"이며, cos 23。5′40"=0.91406 이다. 따라서log 0.91406 =- 0.039026 이지만, 대 | | | | | | | | | | | | +| 즉, 위표에서측선AB의방위각은23°5′40"이며, cos 23°5′40"=0.91406 이다. 따라서log 0.91406 =- 0.039026 이지만, 대 | | | | | | | | | | | | | 수표에는10 + (- 0.039026) =9.960974 를기재한다. | | | | | | | | | | | | -(예제) 조림예정지를 트랜싯의 트래버어스에 의하여 측량한 결과, 측선 AB의 거리67.3m, 방위각 θ= 23。55′40"였다. 대수에 의한 위거 및 경거를 구하여라. (풀이) AB = 67.30m, 방위각 23。55′40"이므로 log 67.30 = 1.828015,10 + log cos 23。55′40"= 10 + log 0.91406 = 9.96097410 + log sin 23。55′40"= 10 + log 0.40558 = 9.608082 이므로위거의 대수 = 1.828015 + 9.960974 = 11.788989 - 10 = 1.788989 경거의 대수 = 1.828015 + 9.608082 = 11.436097 - 10 = 1.4360971.788989 = + 61.52 위거 = 101.436097 = + 27.30 경거 = 10 +(예제) 조림예정지를 트랜싯의 트래버어스에 의하여 측량한 결과, 측선 AB의 거리67.3m, 방위각 θ= 23°55′40"였다. 대수에 의한 위거 및 경거를 구하여라. (풀이) AB = 67.30m, 방위각 23°55′40"이므로 log 67.30 = 1.828015,10 + log cos 23°55′40"= 10 + log 0.91406 = 9.96097410 + log sin 23°55′40"= 10 + log 0.40558 = 9.608082 이므로위거의 대수 = 1.828015 + 9.960974 = 11.788989 - 10 = 1.788989 경거의 대수 = 1.828015 + 9.608082 = 11.436097 - 10 = 1.4360971.788989 = + 61.52 위거 = 101.436097 = + 27.30 경거 = 10 - ○ 경위거표에 의한 경위거의 계산(표 5-2-25)와 같은 경위거표(經緯距表 : Traverse Table)란 측선 및 방위각의 여러 가지 값에 대하 -여 경거 및 위거를 계산하는 것으로 곱셈의 필요없이 덧셈으로써만 계산하도록 되어 있는 표이다. (예제) 측선 AB의 거리 25.10m, 방위각 = 30。5′일 때 다음의 경위거표를 이용하여 위거 및경거를 구하여라. 표 5-2-25. 경위거표(예)a) 30。위거 +여 경거 및 위거를 계산하는 것으로 곱셈의 필요없이 덧셈으로써만 계산하도록 되어 있는 표이다. (예제) 측선 AB의 거리 25.10m, 방위각 = 30°5′일 때 다음의 경위거표를 이용하여 위거 및경거를 구하여라. 표 5-2-25. 경위거표(예)a) 30°위거 | 분 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | | |---|---|---|---|---|---|---|---|---|---|---| @@ -432,7 +436,7 @@ N | 4 | 0.8654 | 1.7309 | 2.5963 | 3.4618 | 4.3272 | 5.1926 | 6.0581 | 6.9235 | 7.7890 | 56 | | 5 | 0.8653 | 1.7306 | 2.5959 | 3.4612 | 4.3265 | 5.1918 | 6.0571 | 6.9224 | 7.7877 | 55 | -b) 30。경거 +b) 30°경거 | 분 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | | |---|---|---|---|---|---|---|---|---|---|---| @@ -443,7 +447,7 @@ b) 30。경거 | 4 | 0.5010 | 1.0020 | 1.5030 | 2.0040 | 2.5050 | 3.0061 | 3.5071 | 4.0081 | 4.5091 | 56 | | 5 | 0.5013 | 1.0025 | 1.5038 | 2.0050 | 2.5063 | 3.0076 | 3.5088 | 4.0101 | 4.5113 | 55 | -(풀이) (표 5-2-25)와 같은 경위거표에서 위거 30。의 위거란에서 5′(분)의 줄을 보면1은 0.8653, 2는 1.7306, 5는 4.3265, … , 9는 7.7877이다. 여기서, 25.10m = 2×10 + 5 + 1 ÷ 10 으로 풀이할 수 있으므로 측선 AB의 위거 = (1.7306×10)+4.3265+(0.8653÷10) = 21.71903 또한, 경거 30。의 경거란에서 5′(분)의 줄을 보면,1은 0.5013, 2는 1.0025, 5는 2.5063, … , 9는 4.5113 이므로 측선 AB의 경거 = (1.0025×10)+2.5063+(0.5013÷10) = 12.58143 따라서, 위거 = 21.72m, 경거 = 12.58m +(풀이) (표 5-2-25)와 같은 경위거표에서 위거 30°의 위거란에서 5′(분)의 줄을 보면1은 0.8653, 2는 1.7306, 5는 4.3265, … , 9는 7.7877이다. 여기서, 25.10m = 2×10 + 5 + 1 ÷ 10 으로 풀이할 수 있으므로 측선 AB의 위거 = (1.7306×10)+4.3265+(0.8653÷10) = 21.71903 또한, 경거 30°의 경거란에서 5′(분)의 줄을 보면,1은 0.5013, 2는 1.0025, 5는 2.5063, … , 9는 4.5113 이므로 측선 AB의 경거 = (1.0025×10)+2.5063+(0.5013÷10) = 12.58143 따라서, 위거 = 21.72m, 경거 = 12.58m ### 마. 평면곡선(horizontal curve)의 설치 #### 1) 곡선의 종류 @@ -454,25 +458,25 @@ b) 30。경거 - ⑤ 완화곡선(Transition Curve) {그림 5-2-43의 (e)}㉮ 3차포물선(Parabolic) : 접선장에 비례하여 Cant의 증가 또는 곡율반경이 감소하는 곡선 - 주로 철도에 이용 ㉯ 쌍곡선(Clothoid) : 완화곡선장에 비례하여 Cant의 증가 또는 곡율반경이 감소하는 곡선 - 주로 도로에 이용 ㉰ 연주곡선(Lemniscate) : 현장에 비례하여 Cant의 증가 또는 곡률반경이 감소하는 곡선 - - 주로 입체교차로에 이용 -![그림 71-2](<../pic/산림과임업기술(임도)_p71_2.png>) +![노선측량_그림5-2-43_곡선의종류_01](<../pic/노선측량_그림5-2-43_곡선의종류_01.png>) -![그림 71-1](<../pic/산림과임업기술(임도)_p71_1.png>) +![노선측량_그림5-2-43_곡선의종류_02](<../pic/노선측량_그림5-2-43_곡선의종류_02.png>) -y램니스케이트(p-C/L)R2R1C:정수클로소이드(p=C/ℓ)R2R3RR13차포물선 L(p=C/x)ℓR20xR1R4x(a)(b)(c)(d)(e)그림 5-2-43. 곡선의 종류 +그림 5-2-43. 곡선의 종류 #### 2) 곡선부의 명칭과 산출식 - ① 곡선시점(Beginning of Curve : BC) = A - ② 곡선종점(End of Curve : EC) = B - ③ 교점(Intersection Point : IP) = V - ④ 곡선반지름(Radius : R) = OA=OB -- ⑤ 곡선장(Curve Length : CL) = AHB = 0.017453 R·I。= 2π·R·I。/360。 +- ⑤ 곡선장(Curve Length : CL) = AHB = 0.017453 R·I°= 2π·R·I°/360° - ⑥ 장현(Long Chord : C) = AB = 2 R·sin (I/2) - ⑦ 절선장(Tangent Length : TL 혹은 T) = VA = VB = R·tan(I/2) - ⑧ 외거(외할)(External Distanse : E) = VH = R{sec(I/2) - 1)} - ⑨ 중앙종거(Middle Ordinate : M) = HF = R {1- cos(I/2)}= C2 / 8R - ⑩ 교각(편각) = (Intersection Angle : IA 또는 I) = I -![그림 72-1](<../pic/산림과임업기술(임도)_p72_1.png>) +![노선측량_그림5-2-44_곡선부의구조](<../pic/노선측량_그림5-2-44_곡선부의구조.png>) V @@ -487,18 +491,18 @@ BACFERD 그림 5-2-44. 곡선부의 구조 - ② 이미 설치된 2∼3개 점의 지물로부터 그 위치를 측정하여 직선부의 방향을 정한 후 말뚝을 박고 두 직선의 교각(交角)을 측정한다. - ③ 이때 장애물이나 지형 관계상 교점이 두 직선상에서 보이지 않거나 접근할 수 없는 경우에는 두 절선상(TL)에 임의의 두 점을 선택하여 ∠α와 ∠β를 측정하면 IA = ∠α+ ∠β에 의하여 구할 수 있다. -![그림 72-2](<../pic/산림과임업기술(임도)_p72_2.png>) +![노선측량_그림5-2-45_두절선상교점설치법](<../pic/노선측량_그림5-2-45_두절선상교점설치법.png>) IIP αβECBCR 그림 5-2-45. 두 절선상에서 교점설치법 #### 4) 편각에 의한 곡선의 설치 - ① (그림 5-2-46)과 같이 교점(점 V)에 트랜싯을 세우고 교각(I)을 측정하여 절선장(VA 또는 VB = R·tan (I/2))만큼 떨어진 지점에 각각 곡선의 시점(BC)과 종점(EC)을 설정한다. -![그림 73-1](<../pic/산림과임업기술(임도)_p73_1.png>) +![노선측량_그림5-2-46_편각에의한곡선설치법](<../pic/노선측량_그림5-2-46_편각에의한곡선설치법.png>) -VIP3 ℓδ1+2δP2 δ2 δ1+δℓδ1PnA(BC)B(EC)P1 ℓ2D2δ2δR2δ1 그림 5-2-46. 편각에 의한 곡선설치법 +그림 5-2-46. 편각에 의한 곡선설치법 -- ② BC점에 트랜싯을 옮겨 설치하고 ∠VAB = (I/2) 되는지를 검사한 다음 버어니어의 0을수평분도원의 0。에맞추고, 상부고정나사를 잠근다. +- ② BC점에 트랜싯을 옮겨 설치하고 ∠VAB = (I/2) 되는지를 검사한 다음 버어니어의 0을수평분도원의 0°에맞추고, 상부고정나사를 잠근다. - ③ AV선에 망원경을 향하게 하여 하부고정나사를 잠그고, 상부고정나사를 푼 다음 상부를 δ= ℓ1 / 2R(radian) = (1,718.87′×ℓ1) / R 만큼 회전하여 시준선 AV상에 BC점에서 거리 ℓ1인 점 P1에 측점말뚝을 박고 못을 박아 중심선을 확정한다. ※ ㉮ 일반도로 측량에서는 ℓ ≤ ( R / 10) 로서 대개 20m로 하고 있으나, 임도 측량에서는 지형의 여건에 따라 적정하게 조정하여야 할 것이다. ㉯ 만일 노선의 기점으로부터 20m 간격으로 말뚝을 박을 경우, 즉 DAP1 = P1P2 = P2P3 = … = ℓ이 되게 할 경우에 직선부의 마지막 말뚝(그림 5-2-46의 점 D)이 BC점과 일치하지 않을 때가 있다. 이때 AP1(= ℓ1 = ℓ-DA)을 시단현(始端弦) 이라 하며,AP1에 대한 편각(δ 1) = 1718.87′×ℓ1/R이 된다. - ④ 상부고정나사가 풀어진 상태에서 수평각이 2δ(또는 δ1+δ)가 될 때까지 상부를 회전하고, 이 시준선상에 점 P1에서 거리 ℓ이 되는 곳에 점 P2를 설정하여 측점말뚝을 박고 못을 박아 중심선을 확정한다. ※ APn의 편각 = δ1+(n-1)×δ @@ -506,7 +510,7 @@ VIP3 ℓδ1+2δP2 δ2 δ1+δℓδ1PnA(BC)B(EC)P1 ℓ2D2δ2δR2δ1 그림 5-2-46. - ⑥ 마지막 말뚝 Pn과 E. C가 일치하지 않을 경우에 PnB(= ℓ2 = AB-APn)를 종단현(終端弦)이라 하며, BPn에 대한 편각(δ 2) =1718.87′×(ℓ2/R)가 된다. -(예제1) 광능시험림 임도내 I. P 16에 대한 곡선 제원이 I = 32。15', R = 200m이고, 노선기점에서 I. P 16까지의 거리는 1,200.50m이다. 기점부터 20m 간격으로 측점말뚝을 설치할 때 각 측점의 거리와 제원을 구하라. (풀이) 절선장(T. L) = 200×tan(32。15'/2) = 57.82m 외거(E) = 200×{sec(32。15'/2)-1} = 8.19m 곡선장(C. L) = 0.017453×200×32。15'= 112.57m 곡선시점(B. C) = 1200.50-57.82 = 1,142.68m 곡선종점(E. C) = 1200.50-57.82+112.57 = 1,255.25m 시단현(l1) = 20-2.68 = 17.32m 종단현(l2) = 1,255.25-(62×20) = 15.25m δ= (ℓ/R)×1,1718.87'= (20.00/200)×1,1718.87'= 2。51'53 δ1 = (ℓ1/R)×1,1718.87'= (17.32/200)×1,1718.87'= 2。28'51 δ2 = (ℓ2/R)×1,1718.87'= (15.25/200)×1,1718.87'= 2。11'04 따라서, B. C점에서 각 측점말뚝의 편각은 (표 5-2-26)과 같다. 표 5-2-26. B. C점에서 각 측점의 편각 +(예제1) 광능시험림 임도내 I. P 16에 대한 곡선 제원이 I = 32°15', R = 200m이고, 노선기점에서 I. P 16까지의 거리는 1,200.50m이다. 기점부터 20m 간격으로 측점말뚝을 설치할 때 각 측점의 거리와 제원을 구하라. (풀이) 절선장(T. L) = 200×tan(32°15'/2) = 57.82m 외거(E) = 200×{sec(32°15'/2)-1} = 8.19m 곡선장(C. L) = 0.017453×200×32°15'= 112.57m 곡선시점(B. C) = 1200.50-57.82 = 1,142.68m 곡선종점(E. C) = 1200.50-57.82+112.57 = 1,255.25m 시단현(l1) = 20-2.68 = 17.32m 종단현(l2) = 1,255.25-(62×20) = 15.25m δ= (ℓ/R)×1,1718.87'= (20.00/200)×1,1718.87'= 2°51'53 δ1 = (ℓ1/R)×1,1718.87'= (17.32/200)×1,1718.87'= 2°28'51 δ2 = (ℓ2/R)×1,1718.87'= (15.25/200)×1,1718.87'= 2°11'04 따라서, B. C점에서 각 측점말뚝의 편각은 (표 5-2-26)과 같다. 표 5-2-26. B. C점에서 각 측점의 편각 | 기점에서부터의 거리(m) | | 편각 | 비고 | |---|---|---|---| @@ -518,17 +522,17 @@ VIP3 ℓδ1+2δP2 δ2 δ1+δℓδ1PnA(BC)B(EC)P1 ℓ2D2δ2δR2δ1 그림 5-2-46. | 1,240 | | 13°56′23″ | | | 1,255 | .25 | 16°7′27″ | E.C | -(예제2) 곡선함수표를 이용하여 I=37。30′, R=300m, IP까지거리=1,150.70m일때 20m 간격으로 단곡선의 중심말뚝을 설치하여라. (풀이) 곡선 함수표에서 I=37。30′에 대한 곡선 제원은 T. L=33.945, C. L=65.450, E= 5.604 이 고 이는 R=100m일 경우이므로 이를 R=300m로 조정하면 +(예제2) 곡선함수표를 이용하여 I=37°30′, R=300m, IP까지거리=1,150.70m일때 20m 간격으로 단곡선의 중심말뚝을 설치하여라. (풀이) 곡선 함수표에서 I=37°30′에 대한 곡선 제원은 T. L=33.945, C. L=65.450, E= 5.604 이 고 이는 R=100m일 경우이므로 이를 R=300m로 조정하면 - ○ T. L = 33.945×300/100 = 101.835m - ○ E = 65.450×300/100 = 196.350m - ○ C. L = 5.604×300/100 = 16.812m이므로 - ○ BC = IP-T. L = 1,150.70-101.835 = 1,048.865m -- ○ EC = IP-T. L+C. L = 1,150.70-101.835+196.350 = 1,245.215m 또한, R = 300m일 때 ℓ= 20m에 대한 편각 δ는 곡선설치표에서 1。54′35"이다. 따라서 (BC 1,048.865m에서 가장 가까운 20배수는 1,060.0m이고, EC 1,245.215m에서 가장 가까운 20의 배수는 1240.0m이므로 +- ○ EC = IP-T. L+C. L = 1,150.70-101.835+196.350 = 1,245.215m 또한, R = 300m일 때 ℓ= 20m에 대한 편각 δ는 곡선설치표에서 1°54′35"이다. 따라서 (BC 1,048.865m에서 가장 가까운 20배수는 1,060.0m이고, EC 1,245.215m에서 가장 가까운 20의 배수는 1240.0m이므로 - ○ 시단현 ℓ1 = 60-48.865 = 11.135m - ○ 종단현 ℓ2 = 45.215-40 = 5.215m 이에 대한 편각 δ1과 δ2는 곡선 설치표에서 R = 300m 란에서 ℓ을 보면, -- ○ 시단현 10m … 0。57′18" -- ○ 종단현 5m … 0。28′39"1m … 0。5′44"0.2m … 0。1′09"0.1m … 0。0′34"+ 0.01m … 0。0′03"+ 0.03m … 0。0′10"δ2 =0。29′51"δ1 =1。3′46"각도를 초(")까지 측정하였으면 비례보간법에 의하여 계산한다. 따라서 BC에서 각 측점말뚝의 편각은 (표 5-2-27)과 같다. 표 5-2-27. BC에서 각 측점의 편각 +- ○ 시단현 10m … 0°57′18" +- ○ 종단현 5m … 0°28′39"1m … 0°5′44"0.2m … 0°1′09"0.1m … 0°0′34"+ 0.01m … 0°0′03"+ 0.03m … 0°0′10"δ2 =0°29′51"δ1 =1°3′46"각도를 초(")까지 측정하였으면 비례보간법에 의하여 계산한다. 따라서 BC에서 각 측점말뚝의 편각은 (표 5-2-27)과 같다. 표 5-2-27. BC에서 각 측점의 편각 | 측점말뚝 | | 편 각 | 비 고 | 측점말뚝 | | 편 각 | 비 고 | |---|---|---|---|---|---|---|---| @@ -540,33 +544,33 @@ VIP3 ℓδ1+2δP2 δ2 δ1+δℓδ1PnA(BC)B(EC)P1 ℓ2D2δ2δR2δ1 그림 5-2-46. (그림 5-2-47)의 점 A에서 4)항의 방법으로 점3까지 설치하고 점4의 위치를 설치하고자 할 때 장애물이 있어서 방향선을 정하지 못할 경우에는 다음의 방법으로 정한다. - ① (그림 5-2-47) 같이 점 EC에 트랜싯을 설치하고 점 5, 4, 3을 4)항의 방법으로 설치한다. (방법 1) -- ② 점 3에 트랜싯을 세우고, 수평분도원 0。와 버어니어 0을 일치시켜 점 A(BC)를 시준하고 상·하고정나사를 고정시킨 후 망원경을 반전한다. +- ② 점 3에 트랜싯을 세우고, 수평분도원 0°와 버어니어 0을 일치시켜 점 A(BC)를 시준하고 상·하고정나사를 고정시킨 후 망원경을 반전한다. - ③ 상부고정나사를 풀고 상부를 d3만큼 회전시키면 점 3의 접선이 되므로 4)항의 방법에 의하여 점 4, 5를 측정한다(방법 2). -![그림 75-1](<../pic/산림과임업기술(임도)_p75_1.png>) +![노선측량_그림5-2-47_시준선장애물곡선설치법](<../pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png>) IP3′d33(3의 접선)d3d4d2d524d151(EC)A(BC)그림 5-2-47. 시준선상에 장애물이 있을 경우의 곡선설치법 ##### 나) 교점(I.P)에 접근하지 못하는 경우 - ① (그림 5-2-48)과 같이 점 P가 장애물 중에 있어 접근하지 못할 때에는 EP선 과는 DP선의 선상에 각각 임의의 점 a와 b를 정한다. -![그림 76-1](<../pic/산림과임업기술(임도)_p76_1.png>) +![노선측량_그림5-2-48_교점접근불가곡선설치법](<../pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png>) PI β3 β1 β2ba θ2 θ1ABROED 그림 5-2-48. 교점에 기계를 설치할 수 없을 경우의 곡선 설치법 -- ② 점 a와 b에 트랜싯을 설치하여 각 θ1, θ2 및 ab의 거리를 측정하여 Aa와 Bb의 거리를 구한다. β1 = 180。- θ1, β2 = 180。- θ2, β3 = 180。- (β1+β +- ② 점 a와 b에 트랜싯을 설치하여 각 θ1, θ2 및 ab의 거리를 측정하여 Aa와 Bb의 거리를 구한다. β1 = 180°- θ1, β2 = 180°- θ2, β3 = 180°- (β1+β #### 2) -I = 180。- β₃에서 Pa = (ab·sinβ2) / sinβ3Pb = (ab·sinβ1) / sinβ3Aa = T. L - Pa, Bb = T. L - Pb +I = 180°- β₃에서 Pa = (ab·sinβ2) / sinβ3Pb = (ab·sinβ1) / sinβ3Aa = T. L - Pa, Bb = T. L - Pb - ③ BC와 EC가 결정되면 4)항의 방법에 의하여 곡선을 설치한다. ##### 다) BC와 EC가 모두 장애물 중에 있을 경우 - ① (그림 5-2-49)와 같이 EP와 DP의 거리를 측정하여 곡선제원을 계산하고 트랜싯을 점 P에 설치하여 θ/2 를 측정하여 PG의 방향을 설정한다. - ② PG선에 E = PG = R{(sec(I/2) - 1)}이 되도록 점 G를 설정한다. - ③ 점 G에 트랜싯을 설치하여 PG에 대하여 직각방향 PGD′를 설정하면 GD′는 호의 접선이 되므로 BC와 EC방향의 곡선을 4)항의 방법으로 설정한다. 이때 BC = EP - TL이고,EC = BC + CL 이며, 점 G까지의 거리는 BC + (CL/2)이 된다. 그러므로 전후의 단현을 계산하고 양측의 곡선을 설정한다. -![그림 77-1](<../pic/산림과임업기술(임도)_p77_1.png>) +![노선측량_그림5-2-49_BC와EC장애물곡선설치법](<../pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png>) -PI θθ22 θT90。DG 하천 ECBCBRAOE 그림 5-2-49. BC와 EC에 장애물이 있을 경우의 곡선설치법 +그림 5-2-49. BC와 EC에 장애물이 있을 경우의 곡선설치법 #### 6) 중앙종거에 의한 곡선설치 @@ -578,7 +582,7 @@ PI θθ22 θT90。DG 하천 ECBCBRAOE 그림 5-2-49. BC와 EC에 장애물이 - ④ AD의 길이를 측정하여 그 중점 E1에서 직각선을 테이프로 측정하여 M′= M/4 만큼 되는 점 D1을 산정한다. - ⑤ 다음에 AD1을 연결하여 그 중점에서 M"= M′/4 만큼 되는 점 D2를 측정하여 곡선을 설정한다. 이와 같은 방법으로 EC까지 계속한다. -![그림 77-2](<../pic/산림과임업기술(임도)_p77_2.png>) +![노선측량_그림5-2-50_중앙종거곡선설치법](<../pic/노선측량_그림5-2-50_중앙종거곡선설치법.png>) PITLTLM′DM"D1D2ME1E. C. B. C. EABIR0 그림 5-2-50. 중앙종거에 의한 곡선 설치법 @@ -587,7 +591,7 @@ PITLTLM′DM"D1D2ME1E. C. B. C. EABIR0 그림 5-2-50. 중앙종거에 의한 곡 - ① (그림 5-2-51)에서 AD//EB라고 하면 점D, E(두곡선의 IP)를 선정하고 DE상의 1점 C(반향점)를 설치하여 CD, CE를 측정한다. - ② 이때는 이미 접선장(T. L)과 교각(I)를 알고 있으므로 곡선반지름 r과 R을 산출하여 단곡선 설치의 방법 중 한가지를 선택하여 곡선 AC와 곡선 BC를 설정한다. -![그림 78-2](<../pic/산림과임업기술(임도)_p78_2.png>) +![노선측량_그림5-2-51_평행한두직선반향곡선](<../pic/노선측량_그림5-2-51_평행한두직선반향곡선.png>) O′RRAD θC θrEBO 그림 5-2-51. 평행한 2직선구간의 반향곡선 설치법 @@ -598,7 +602,7 @@ O′RRAD θC θrEBO 그림 5-2-51. 평행한 2직선구간의 반향곡선 설 - ④ BD간을 연결하는 곡선을 단곡선 설치의 방법에 의하여 설치하면 점 A를 통과하는 소요 곡선이 된다. - ⑤ ∠HAD = 1/2·K, AD = 2 R·sin(1/2·K)이므로 점 D를 설정하고, ∠ADN = 1/2·K이므로 DN의 방향선을 구한다. -![그림 78-1](<../pic/산림과임업기술(임도)_p78_1.png>) +![노선측량_그림5-2-52_평행하지않은두직선반향곡선](<../pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png>) 그림 5-2-52. 평행하지 않은 2직선 구간의 반향곡선 설치법 @@ -609,7 +613,7 @@ O′RRAD θC θrEBO 그림 5-2-51. 평행한 2직선구간의 반향곡선 설 - ③ TL과 R이 결정되면 AC구간은 단곡선으로서 설치될 수 있고, 다음에 TL =EC와 θ1을 실측하여 구하면, R′= BO′= EC / tan(θ1/2) = TL / tan(θ1/2) = TL cot(θ1/2) - ④ R′를 계산하여 CB구간도 단곡선으로 설치할 수 있다. -![그림 79-1](<../pic/산림과임업기술(임도)_p79_1.png>) +![노선측량_그림5-2-53_복심곡선설치법](<../pic/노선측량_그림5-2-53_복심곡선설치법.png>) PIDCEA2BROR′O′그림 5-2-53. 복심곡선 설치법 @@ -617,13 +621,13 @@ PIDCEA2BROR′O′그림 5-2-53. 복심곡선 설치법 - ① 배향곡선(背向曲線 : Hair Pin Curve)은 부임도나 작업도를 연결하기에 편리한 곳이지만, 토사유출과 임지내 생산면적의 감소 등으로 인하여 불합리하기 때문에 사면기울기가 40%이하이고 지반이 안정된 곳에 설치하고 동일사면에 1개 이상은 설치하지 않아야 한다. - ② 곡선내부의 외쪽물매는 5%, 노폭은 6m 정도가 좋으며, 부득이 동일사면에 (그림 5-2-54)와 같이 1개 이상을 설치할 경우에는 다음식에 의하여 적정간격을 구한다. ※ 배향곡선의 적정간격(d) = 0.5·RS·gs / gr 여기서, RS : 적정임도간격(m)gs : 산지사면 기울기(%)gr : 종단물매(%) -![그림 79-2](<../pic/산림과임업기술(임도)_p79_2.png>) +![노선측량_그림5-2-54_배향곡선의간격](<../pic/노선측량_그림5-2-54_배향곡선의간격.png>) dgsRSgr 그림 5-2-54. 배향곡선의 간격 - ③ 배향곡선 역시 (그림 5-2-55)와 같이 단곡선의 연속이므로 각각의 접선과 반지름을 결정하여 단곡선의 설정법과 같이 곡선을 설치한다. ㉮ (그림 5-2-55)에서 배향곡선의 반경을 r로 하고, 지형조건에 맞게 점O에서 약 2r~2.5r 떨어진 곳(ℓm)에 점 P, S를 절선상에 설정한다. ㉯ 점 P에서 배향곡선을 둘러싸는 새로운 절선다각형 P, Q, R, S를 설치하면 -![그림 80-1](<../pic/산림과임업기술(임도)_p80_1.png>) +![노선측량_그림5-2-55_배향곡선설치법](<../pic/노선측량_그림5-2-55_배향곡선설치법.png>) RJsO βI αβP γr(α+β)/2HdQ1.5r 그림 5-2-55. 배향곡선 설치법 r-1 ( )β= cos ℓr-1 ( )γ= cos ℓα+β2 - r2 + r cot ( )√d = ℓ2 의 관계가 있고, ℓ = 2 r 로 하면, α+β√γ= 30°, d = r { 3 + cot ( )} 로 된다.2 ㉢ 따라서 배향곡선을 현지에 설치할 경우에는 먼저 점P, S에서 γ°만큼 곡선의 외측에 비스듬하게 통과하는 선상의 d(m)만큼 떨어진 곳에 점Q, P를 구하고, ㉣ 다음에 이들점을 교점으로 한 반경 r인 원을 절선 다각형에 내접시키는 동시에 교점P, S에도 1.5r 정도의 원을 외접시키면 배향곡선이 된다. (예제) 광능시험림내 임도시설 예정지내 배향곡선을 동일한 사면에 부득이 2 개를 설치하여야 할 곳이 있다. 임도간격을 300m 로 하고, 산지사면기울기 40%, 종단물매 9% 이면, 배향곡선의 적정간격은 얼마인가 ? (풀이) 배향곡선의 적정간격(d) = 0.5 × 300 × 40 ÷ 9 = 666 ≒ 700m @@ -655,7 +659,7 @@ RJsO βI αβP γr(α+β)/2HdQ1.5r 그림 5-2-55. 배향곡선 설치법 r-1 ( - ② 「가시거리 < 종단곡선길이」인 경우 √√L = S2·G/ {2( h1 +h2 )2}(그림 5-2-56의 ⓑ 참조) - ③ 「가시거리 = 종단곡선길이」인 경우 2 / G √√L = S = 2( h1 +h2 )여기서, S : 가시거리(m)L : 종단곡선의 길이(m)G : 두 기울기선의 종단물매대수차(%) = |i1-i2|/100h1 : 노면에서 운전수 눈의 높이(m)h2 : 장애물의 높이(m) -![그림 83-1](<../pic/산림과임업기술(임도)_p83_1.png>) +![노선측량_그림5-2-56_종단곡선길이와가시거리](<../pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png>) ⓐSh1h2i1i2L ⓑSh2i1i2h1L 그림 5-2-56. 종단곡선의 길이와 가시거리의 관계 @@ -663,40 +667,44 @@ RJsO βI αβP γr(α+β)/2HdQ1.5r 그림 5-2-55. 배향곡선 설치법 r-1 ( R ≒ 100L / |i1-i2|여기서, R : 종단곡선의 반지름(m)L : 종단곡선의 길이(m)|i1-i2| : 종단물매의 대수차의절대치(%)(예제) 2)-가)-①의 (예제)에서 종단곡선의 반지름을 산출하여라. (풀이) R = (100×40)/|17-3|= 285.7 ≒ 280m -![그림 83-2](<../pic/산림과임업기술(임도)_p83_2.png>) +![노선측량_그림5-2-57_종단곡선길이별가시거리](<../pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png>) -1000500200S1005020S >Lh1=1.4mS ) +- $Y_n$: 횡거 $\chi$에 대한 종거(m) +- $L$: 종단곡선의 길이(m) +- $d$: 인접한 두 기울기선의 물매차 절대값(%)인 $|i_1-i_2|$ +- $\chi$: 종단곡선의 시점 또는 종점에서 종거를 구하는 점까지의 횡거(m) -E. Ci2%Y1Y3Y2B. CVi1%H4H3H1 H2H0H2′H1′H3′xx 수준면 780800810820840 그림 5-2-58. 종단곡선상 각 측점의 종횡거산출(예제) 2)-가)-②의 (예제)에서 종단곡선의 시곡점과 종곡점의 사이에 위치한 각 측점의 종 거를 구하여라. (풀이) 두 기울기선의 물매차는 14%, 종단곡선의 길이는 60m이므로 +![노선측량_그림5-2-58_종단곡선측점종횡거](<../pic/노선측량_그림5-2-58_종단곡선측점종횡거.png>) + +그림 5-2-58. 종단곡선상 각 측점의 종횡거 산출 + +(예제) 앞의 예제에서 종단곡선의 시곡점과 종곡점 사이에 위치한 각 측점의 종거를 구하여라. 두 기울기선의 물매차는 14%, 종단곡선의 길이는 60m이다. - ① 시곡점 : 810-(60/2) = 780m - ② 종곡점 : 810+(60/2) = 840m -- ③ (그림 5-2-58)을 참고하면 800m와 820m에 위치한 측점은 종단곡선의 시·종곡점으로 부터의 횡거가 각각 20m이므로 종거가 동일하다. Y1 = Y3 = (14×20 -2) /(200×60) = 0.47m -- ④ 810m에 위치한 측점은 종단곡선 시곡점으로부터의 횡거가 30m이므로 Y2 = (14×30 -2) /(200×60) = 1.05m +- ③ 그림 5-2-58을 참고하면 800m와 820m에 위치한 측점은 종단곡선의 시·종곡점으로부터의 횡거가 각각 20m이므로 종거가 동일하다. $Y_1=Y_3=(14\times20^2)/(200\times60)=0.47\,\mathrm{m}$ +- ④ 810m에 위치한 측점은 종단곡선 시곡점으로부터의 횡거가 30m이므로 $Y_2=(14\times30^2)/(200\times60)=1.05\,\mathrm{m}$ #### 5) 종단곡선상의 표고 산출 -Hn′= Hn ± (i/100)·χ, Hn = Hn′± Yn (그림 5-2-57 참조)여기서, Hn′: B. C 또는 E. C에서 χ만큼 떨어진 점의 표고(m)Hn : B. C 또는 E. C의 표고(m) 또는 종단곡선상의 표고(m)Yn : B. C 또는 E. C에서 χ만큼 떨어진 점과 곡선과의 종거(m)(예제) 4)항의 (예제)에서 각 점의 표고를 구하여라(단, B. C의 표고는 100.57m이다). (풀이) ① 780m 지점의 표고(H0) = B. C의 표고 = 100.57m -- ② 800m 지점의 표고 : H1′= 100.57+(3/100)×20 = 101.17mY1 = (14×20 -2) /(200×60) = 0.47m(⇒ B.C부터 계산) +$$H_n'=H_n\pm\frac{i}{100}\chi,\qquad H_n=H_n'\pm Y_n$$ -H1 = 101.17+0.47 = 101.64m -- ③ 810m 지점(V)의 표고 : H2′= 100.57+(3/100)×30 = 101.47mY2 = (14×30 -2) /(200×60) = 1.05m +- $H_n'$: B.C 또는 E.C에서 $\chi$만큼 떨어진 점의 표고(m) +- $H_n$: B.C 또는 E.C의 표고 또는 종단곡선상의 표고(m) +- $Y_n$: B.C 또는 E.C에서 $\chi$만큼 떨어진 점과 곡선과의 종거(m) -H2 = 101.47+1.05 = 102.52m -- ④ 820m 지점의 표고 : H3′= 101.47+(17/100)×10 = 103.17mY3 = (14×20 -2) /(200×60) = 0.47m(⇒ E.C부터 계산) +(예제) 앞의 예제에서 각 점의 표고를 구하여라. 단, B.C의 표고는 100.57m이다. -H3 = 103.17+0.47 = 103.64m -- ⑤ 840m 지점(V+30m)의 표고 : H4′= 101.47-(17/100)×30 = 106.57mH4 = 106.57+0 = 106.57m +- ① 780m 지점: $H_0=100.57\,\mathrm{m}$ +- ② 800m 지점: $H_1'=100.57+(3/100)\times20=101.17\,\mathrm{m}$, $Y_1=0.47\,\mathrm{m}$, $H_1=101.64\,\mathrm{m}$ +- ③ 810m 지점(V): $H_2'=100.57+(3/100)\times30=101.47\,\mathrm{m}$, $Y_2=1.05\,\mathrm{m}$, $H_2=102.52\,\mathrm{m}$ +- ④ 820m 지점: $H_3'=101.47+(17/100)\times10=103.17\,\mathrm{m}$, $Y_3=0.47\,\mathrm{m}$, $H_3=103.64\,\mathrm{m}$ +- ⑤ 840m 지점(V+30m): $H_4'=101.47+(17/100)\times30=106.57\,\mathrm{m}$, $H_4=106.57\,\mathrm{m}$ ### 사. 구조물 및 지질조사 구조물(교량, 배수구, 석축 등)을 설치할 필요가 있는 개소의 위치, 지형, 자재 채취의 양부 등을 상세하게 조사하거나 측량을 실시하여 필요한 자료를 구한다. diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md index 153f2e0f..487f509f 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md @@ -95,7 +95,7 @@ $$ 표 5-2-30. 트래버어스 폐합오차 조정결과 -![그림 89-1](<../pic/산림과임업기술(임도)_p89_1.png>) +![설계_그림5-2-59_합경거와합위거](<../pic/설계_그림5-2-59_합경거와합위거.png>) | 측선 | 조정위거 (+) | 조정위거 (-) | 조정경거 (+) | 조정경거 (-) | 합위거 | 합경거 | |---|---:|---:|---:|---:|---:|---:| @@ -120,7 +120,7 @@ $$ - ① 그림 5-2-60과 같이 기준선 NS선상의 임의의 점 A에서 $Aa'=10\,\mathrm{cm}$가 되도록 점 $a'$를 정하고 수직선 $aa'$를 긋는다. - ② AB선과 NS선의 편각에 대한 탄젠트 값을 구하고 이를 이용하여 점 $a$를 결정한다. 즉, $\tan 28^\circ30'=0.543=5.43/10$이므로 $aa'$의 길이는 5.43cm이다. -![그림 90-1](<../pic/산림과임업기술(임도)_p90_1.png>) +![설계_그림5-2-60_탄젠트값트래버어스제도](<../pic/설계_그림5-2-60_탄젠트값트래버어스제도.png>) 그림 5-2-60. 탄젠트값에 의한 트래버어스제도(예) @@ -149,7 +149,7 @@ $$ - ④ 점 A와 $a$를 연결하면 $\angle aAa'=35^\circ20'$이므로, 그 연장선상에서 적당한 축척의 AB 길이를 취하여 점 B를 정한다. - ⑤ 이와 같은 방법을 반복한다. -![그림 91-1](<../pic/산림과임업기술(임도)_p91_1.png>) +![설계_그림5-2-61_삼각함수값트래버어스제도](<../pic/설계_그림5-2-61_삼각함수값트래버어스제도.png>) 그림 5-2-61. sin, cos 값에 의한 트래버어스제도(예) @@ -159,23 +159,23 @@ $$ | 표5-2-31. 교점제원과선형계산(예) | | | | | | | | | | | | | |---|---|---|---|---|---|---|---|---|---|---|---|---| | ○교점번호: BP | | | | | | | | | | | | | -| 좌 표 | | X | | 470740.500 | | 방위각 | | | | | 188。39′19.41″ | | +| 좌 표 | | X | | 470740.500 | | 방위각 | | | | | 188°39′19.41″ | | | | | Y | | 142679.000 | | | | | | | | | | ○교점번호: IP1 (좌향) | | | | | | | | | | | | | | 곡선종류 | | 원곡선 | | | IP좌표 | | | X | | 470697.8000 | | | | | | | | | | | | Y | | 142672.5000 | | | -| 방위각 | 1 | 188。39′19.41″ | | | 원중심좌표 | | | X | | 470720.5531 | | | -| | 2 | 111。55′46.52″ | | | | | | Y | | 142712.3783 | | | -| IA | 76。43′32.90″ | | | | R | | 36.0000 | | | 거리 | | 43.1919 | +| 방위각 | 1 | 188°39′19.41″ | | | 원중심좌표 | | | X | | 470720.5531 | | | +| | 2 | 111°55′46.52″ | | | | | | Y | | 142712.3783 | | | +| IA | 76°43′32.90″ | | | | R | | 36.0000 | | | 거리 | | 43.1919 | | TL | 28.4953 | | | | SL | | 9.9128 | | | CL | | 48.2083 | | ○측점별위치및제원표 | | | | | | | | | | | | | | 구분 | 누가거리 | | 측점(No) | | X좌표 | | | | Y좌표 | | | 방위각 | | BP | 0.0000 | | 0+00.0000 | | 470740.50 | | | | 142679.00 | | | | -| BC | 14.6966 | | 0+14.6966 | | 470725.97 | | | | 142676.79 | | | 188。39′19.41″ | -| | 20.0000 | | 1+00.0000 | | 470720.69 | | | | 142676.38 | | | 180。12′53.06″ | -| | 40.0000 | | 2+00.0000 | | 470701.68 | | | | 142681.72 | | | 148。23′ 1.50″ | -| | 60.0000 | | 3+00.0000 | | 470688.35 | | | | 142696.29 | | | 116。33′ 9.94″ | -| EC | 62.9049 | | 3+02.9049 | | 470687.16 | | | | 142698.93 | | | 111。55′46.52″ | +| BC | 14.6966 | | 0+14.6966 | | 470725.97 | | | | 142676.79 | | | 188°39′19.41″ | +| | 20.0000 | | 1+00.0000 | | 470720.69 | | | | 142676.38 | | | 180°12′53.06″ | +| | 40.0000 | | 2+00.0000 | | 470701.68 | | | | 142681.72 | | | 148°23′ 1.50″ | +| | 60.0000 | | 3+00.0000 | | 470688.35 | | | | 142696.29 | | | 116°33′ 9.94″ | +| EC | 62.9049 | | 3+02.9049 | | 470687.16 | | | | 142698.93 | | | 111°55′46.52″ | - ② 교점에서 절선장(TL)을 끊어 시곡점{(BC), IP간의 거리(43.1919m) - TL(28.4953m) =BC(14.6966m)} 종곡점{(EC), BC(14.6966m) + CL(48.2083m) = EC(62.9049m)}의 측점을 부여하고 교점 내각의 2등분선 방향으로 외할장(E, SL)만큼 중곡점(MC)을 부여한다. - ③ 시곡점과 종곡점에서 각 절선에 대한 수직선을 내려 만나는 점이 곡선의 중심(0)이 되며 그 길이를 곡선 반지름으로 하여 단곡선을 그리면 중심선의 곡선부가 된다. @@ -186,7 +186,7 @@ $$ - ③ 노선이 연속되어 도면이 나누어 질 경우에는 절취선이 겹쳐지게 하여 선형의 방향이 흐트러지지 않도록 하고, 시설 계획된 구조물을 시설위치마다 명칭, 치수 및 수량을 기재한다. - ④ 각 교점에 대한 곡선제원표를 도면의 상단 또는 하단의 빈 공간에 기록한다. -![그림 92-1](<../pic/산림과임업기술(임도)_p92_1.png>) +![설계_그림5-2-62_평면도작성예](<../pic/설계_그림5-2-62_평면도작성예.png>) 그림 5-2-62. 평면도 작성(예) @@ -199,17 +199,17 @@ $$ - ④ 지반고는 종단측량에서 측정계산된 현지의 지반고를 기재하고, 각 측점별로 종축의 높이에 맞게 막대그래프를 그려 중심선의 지반 선형을 나타낼 수 있도록 연결한다. - ⑤ 계획고는 측점별 임도노면의 시공 높이가되며 이를 연결한 선형이 종단계획선이다. -![그림 93-1](<../pic/산림과임업기술(임도)_p93_1.png>) +![설계_그림5-2-63_종단면도작성예](<../pic/설계_그림5-2-63_종단면도작성예.png>) -740.00PIPED=800N. L=6.50SKEW=0.0N. C730.00BOX(W)2.0×2.0NL=7.50SKEW=13.0720.00710.00종단선형성 토 고절 토 고계 획 고지 반 고누가거리측점 BCECIP=1 R=36.0 평면선형 IP=2 R=22.0IA=1-20-20.82 TL=28.495IA=1-55-40.97 TL=31.695BCEC 그림 5-2-63. 종단면도 작성(예)계획선은 임도의 구조와 공사비에 직접적인 영향을 미치게 되므로 성토량과 절토량의 균형이 맞고 계획구배의 선형이 임도내부에서 유로(流路)방향이 된다는 것을 명심하여 알맞게 설정하여야 한다. ※ 영선측량법에서는 측량자가 종단물매, 지형조건, 공사의 난이성, 경제성 등을 참고하여 종단계획선 위주로 측량하기 때문에 설계시에 문제를 발생시키는 일이 거의 없다. 그러나 중심선 측량법에서는 평면측량→평면도, 종단측량→종단면도, 횡단측량→횡단면도의 과정을 모두 마친 후 입체적으로 검토하여야 하므로 현지 지형조건과 각 선형조건에 부합되게 설정하기가 어렵 +그림 5-2-63. 종단면도 작성(예) -- 다. 평면선형과 종단선형으로 예를들면 (그림 5-2-64(a))와 같은 선형은 양호하나 (b)와 같은 +계획선은 임도의 구조와 공사비에 직접적인 영향을 미치게 되므로 성토량과 절토량의 균형이 맞고 계획구배의 선형이 임도 내부에서 유로(流路) 방향이 된다는 것을 명심하여 알맞게 설정하여야 한다. 영선측량법에서는 측량자가 종단물매, 지형조건, 공사의 난이성, 경제성 등을 참고하여 종단계획선 위주로 측량하기 때문에 설계 시에 문제를 발생시키는 일이 거의 없다. 그러나 중심선측량법에서는 평면측량→평면도, 종단측량→종단면도, 횡단측량→횡단면도의 과정을 모두 마친 후 입체적으로 검토하여야 하므로 현지 지형조건과 각 선형조건에 부합되게 설정하기가 어렵다. -선형은 불량한 선형계획이 될 것이다. 따라서 종단계획선의 설정시에는 물매, 지형조건, 공사의 난이도, 경제성에 알맞도록 신중을 기하여야 한다. +평면선형과 종단선형으로 예를 들면 그림 5-2-64(a)와 같은 선형은 양호하나 (b)와 같은 선형은 불량한 선형계획이 될 것이다. 따라서 종단계획선의 설정 시에는 물매, 지형조건, 공사의 난이도, 경제성에 알맞도록 신중을 기하여야 한다. -![그림 94-1](<../pic/산림과임업기술(임도)_p94_1.png>) +![설계_그림5-2-64_평면선형과종단선형](<../pic/설계_그림5-2-64_평면선형과종단선형.png>) -평면곡선의 중곡점 평면 저 저 종단 부정부정부부(a)위상이 맞는 경우 평면곡선의 중곡점 평면 저 저 종단 부정정부 부부(b)위상이 어긋난 경우 그림 5-2-64. 평면선형과 종단선형의 결정 +그림 5-2-64. 평면선형과 종단선형의 결정 - ⑥ 성토고와 절토고란은 지반고>계획고일때 절토고에 그 차를 기재하고, 지반고<계획고이면성토고에 그 차를 기재한다. - ⑦ 종단선형란은 계획선이 같은 물매로 되는 No. a와 No. b 구간에 대한 물매(%)를 말하며, 측점표고가 a>b일 경우에는 하향선(\), a) +![설계_그림5-2-66_구조물도](<../pic/설계_그림5-2-66_구조물도.png>) 그림 5-2-66. 구조물도 작성(예) @@ -271,18 +271,16 @@ $$ - ① 공사시공 기면에 대한 치수, 재료 및 형상 등을 명시한 것을 공사규정이라고 하고, 이를(그림 5-2-67)과 같이 일정하고 공통적인 사항에 대하여 도면으로 그 기준을 표시한 것을 표준도라고 한다. - ② 다음 사항에 관하여 일정한 규정을 정한 경우에는 표준도를 작성한다. ㉠ 절취, 성토, 측구 등 시공기면의 조성폭과 기울기 ㉡ 암벽의 최소 덮은 두께 및 기초구조 등 ㉢ 석축공의 기울기, 공장도, 뒷채움 두께, 콘크리트 두께 등 ㉣ 콘크리트 옹벽공의 상부 두께 ㉤ 돌망태의 사면기울기, 뒷채움 두께 ㉥ 기타 필요한 사항 -![그림 98-1](<../pic/산림과임업기술(임도)_p98_1.png>) +![설계_그림5-2-67_표준도작성예](<../pic/설계_그림5-2-67_표준도작성예.png>) 그림 5-2-67. 표준도 작성(예) #### 6) 용지도 및 용지조서 -- ① 계획노선의 중심선, 시공부지의 경계선(일반적으로 절토사면 머리부터 성토사면 끝까지)을 지적도와 임야도를 기준으로 실시한 용지측량의 결과를 1/600 또는 법정도면의 축척으로 (그림 5-2-68)과 같이 용지도를 작성하고 +- ① 계획노선의 중심선, 시공부지의 경계선(일반적으로 절토사면 머리부터 성토사면 끝까지)을 지적도와 임야도를 기준으로 실시한 용지측량의 결과를 1/600 또는 법정도면의 축척으로 그림 5-2-68과 같이 용지도를 작성한다. -![그림 98-2](<../pic/산림과임업기술(임도)_p98_2.png>) +![설계_그림5-2-68_용지도작성예](<../pic/설계_그림5-2-68_용지도작성예.png>) -470.100No.0 공사시점 No.5No.0+0.00No.10142.700 - -- ① 그림 5-2-68. 용지도 작성(예) +그림 5-2-68. 용지도 작성(예) - ② 임야대장, 토지대장을 기준으로 택지, 농경지, 임야 등을 행정구역별, 소유별, 지목별, 소유자별로 면적을 산출하고 표 5-2-32와 같은 용지조서를 작성한다. 표 5-2-32. 용지조서(예) (단위: m²) @@ -353,7 +351,7 @@ $$ - ② 각 구역을 사각(또는 직삼각)기둥으로 생각하여 각 구역의 면적과 평균높이를 구하는 방법으로 전체체적을 산출한다. - ③ (그림 5-2-69)와 같이 각 꼭지점의 높이를 사용하는 회수에 따라 1번사용(H1), 2번사용(H2), 3번사용(H3), 4번사용(H4)으로 구분하여 다음식에 의하여 산출한다. -![그림 101-1](<../pic/산림과임업기술(임도)_p101_1.png>) +![설계_그림5-2-69_사각주의점고](<../pic/설계_그림5-2-69_사각주의점고.png>) 구역체적과 전체체적은 다음 식으로 산출한다. @@ -388,7 +386,7 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - ③ 기초 콘크리트는 폭(5.1m) × 두께(0.10m) × 길이(6.03m)를 곱하면 3.08㎥가 된다. - ④ 거푸집(합판 3회 사용)은 내벽, 외벽, 받침판으로 구분하여 좌우외벽(2.0m × 6.03m × 2장), 유수구 2개 단면{(1.5 - 0.2 × 2개) × 6.03m × 4장}, 받침판{(2.0 - 0.2 × 2개) × 6.03m × 2장} 및 유수구 귀면{$\sqrt{0.2^2+0.2^2}\times6.03m\times8장$}을 합하면 83.59㎡가 된다. -![Box 구조물 단면 원문](<../pic/설계_그림5-2-70_Box단면도.png>) +![설계_그림5-2-70_Box단면도](<../pic/설계_그림5-2-70_Box단면도.png>) 그림 5-2-70. Box(2.0×1.5×2련×6.03 m) 단면도 @@ -444,7 +442,7 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - ③ 보정된 성토량을 (-)로 하고 절취량을 (+)로 하여 각 측점마다 누적대수화하여 누가토 량을 구한 후 횡축은 종단면도의 축척과 같이 거리별로 측점의 위치를 나타내고 종축은 각 측점까지의 누가토량을 (그림 5-2-71)과 같이 그린다. 이 곡선을 유토곡선(MassCurve)또는 토량곡선이라고 한다. -![그림 105-1](<../pic/산림과임업기술(임도)_p105_1.png>) +![설계_그림5-2-71_유토곡선](<../pic/설계_그림5-2-71_유토곡선.png>) 그림 5-2-71. 유토곡선(예) @@ -576,7 +574,7 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - - 기설도로 : 1개 이상의 기설도로를 반드시 표시 ㉢ 입력내용 기설도로자료 : 통과 행열번호입지자료 : 표고, 경사도, 임종, 영급, ha당 축적기타 : 구역면적, 격자간격, 임도밀도, 한계물매, 임도 및 집재우회율, 집재단가, 임도단가 - ③ 작업 흐름도 -![그림 5-2-72 FRNET 작업 흐름도](<../pic/설계_그림5-2-72_FRNET흐름도.png>) +![설계_그림5-2-72_FRNET흐름도](<../pic/설계_그림5-2-72_FRNET흐름도.png>) - ④ 출력사항 ㉠ 초기화면 상에서 입력한 제한조건의 적용값과 기설도로의 행열번호 ㉡ 선점(選點) 단계별로 선점된 행열번호, 집재재적, 집재비용 및 임도시설비용, 임도연장, 종단물매, 평균집재거리, 선점순서, 가중평균집재거리, 집재거리 표준편차, 개발지수 ㉢ 도로(임도)의 기설·계획·총연장, 계획임도시설비, 적정임도밀도 #### 2) 임도설계의 전산화 @@ -599,7 +597,7 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - - 자료의 통합관리체제 하의 공사별 자료관리 가능 - ② 전산처리 흐름도 -![그림 5-2-73 STmate 전산처리 흐름도](<../pic/설계_그림5-2-73_STmate흐름도.png>) +![설계_그림5-2-73_STmate흐름도](<../pic/설계_그림5-2-73_STmate흐름도.png>) 그림 5-2-73에서 보는 바와 같이 “STmate”는 자료관리에 있어서 일반적으로 “기초자료∼단가계산”의 과정은 모든 공사에 동일하게 적용되므로 공사종류에 관계없이 통합관리되고 있으며, “공사비계산∼간접비계산”의 과정은 해당공사별로 독립관리되고 있다. 그러나 작업을 완료한 후 출력 시에는 해당공사별로 출력이 가능하다. @@ -618,9 +616,13 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - ㉢ 토공수량산출: 그림 5-2-76 - ㉣ 내역서 작성: 그림 5-2-77 -![그림 5-2-74~77 전산처리 흐름도](<../pic/설계_그림5-2-74-75_도면흐름도.png>) +![설계_그림5-2-74_종단면도흐름도](<../pic/설계_그림5-2-74_종단면도흐름도.png>) -![그림 5-2-74~77 전산처리 흐름도 계속](<../pic/설계_그림5-2-76-77_토적내역흐름도.png>) +![설계_그림5-2-75_횡단면도흐름도](<../pic/설계_그림5-2-75_횡단면도흐름도.png>) + +![설계_그림5-2-76_토적계산흐름도](<../pic/설계_그림5-2-76_토적계산흐름도.png>) + +![설계_그림5-2-77_내역서흐름도](<../pic/설계_그림5-2-77_내역서흐름도.png>) - ③ 기초자료 및 입력 ㉠ 설계도화자료 : 지반고, 계획선, 횡단경사, 추정암선, 구조물의 종류·규격, 토성별횡단선형기울기, 노폭, 측구폭. ㉡ 금원화자료 : STmate와 같음 - ④ 출력사항 ㉠ 도면 : 횡단면도, 종단면도, 구조물도 ㉡ 부속서류 : STmate와 같음. @@ -629,7 +631,7 @@ $$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\math - ② 기초자료 작성 및 입력 ㉠ 현지측량인자 : 측점, 토성, 경사도, 거리, 평균거리 ㉡ 종단면도인자 : 성·절토고 ㉢ 횡단선형인자 : 노폭, 측구폭, 절토사면기울기, 성토사면기울기 - ③ 작업 흐름도 -![그림 5-2-78 Earth work 작업 흐름도](<../pic/설계_그림5-2-78_Earthwork흐름도.png>) +![설계_그림5-2-78_Earthwork흐름도](<../pic/설계_그림5-2-78_Earthwork흐름도.png>) - ④ 출력사항 diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md index bd9015c6..669d59b6 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md @@ -13,7 +13,7 @@ - ③ {그림 5-2-79(c)}는 절취고가 7∼10m를 초과하며 암질이 변화하는 경우에 적합한 공법인데 소단을 만들기 때문에 초기 공사비는 많이 소요되나 시공 후 유지비가 적게 소요되며 교통상으로 안전한 이점이 있고 - ④ 소단의 폭은 보통1.5m가 표준이고, 소단에는 5∼10%의 횡단물매를 주며 설치높이의 간격은 절취고에 따라 다르나 5∼10m 간격으로 한다. -![그림 118-1](<../pic/산림과임업기술(임도)_p118_1.png>) +![시공_그림5-2-79_본바닥상태와절취사면](<../pic/시공_그림5-2-79_본바닥상태와절취사면.png>) (a)(b)(c)그림 5-2-79. 본바닥 상태와 절취사면의 형식 표 5-2-41. 절취사면의 표준기울기 @@ -88,7 +88,7 @@ - ② 용수나 배수를 위하여 절취면 끝 접속부에 맹구(용수가 많은 경우 구멍 뚫린 관(有孔菅))를 설치하는 것이 좋다. - ③ 원지반의 지표횡단물매가 1 : 4 이상으로 급할 경우에는 표토를 제거한 후 (그림 5-2-81)과 같이 성토부분의 기초를 계단식 층따기(Bench Cut)로 한다. -![그림 122-2](<../pic/산림과임업기술(임도)_p122_2.png>) +![시공_그림5-2-81_편절토편성토접속횡단면](<../pic/시공_그림5-2-81_편절토편성토접속횡단면.png>) | 층따기 치수(cm) | | | |---|---|---| @@ -101,7 +101,7 @@ 절토구간과 성토구간의 종단방향 접속부에는 (그림 5-2-82)와 같이 접속구간을 설치하여 노상지지력의 불연속성을 보강한다. -![그림 122-1](<../pic/산림과임업기술(임도)_p122_1.png>) +![시공_그림5-2-82_절토성토접속종단면](<../pic/시공_그림5-2-82_절토성토접속종단면.png>) 그림 5-2-82. 절토, 성토 접속부분의 종단면 @@ -358,7 +358,7 @@ | 개소수(n) | | 2 | 3 | 4 | | 5 | | 6 | | 7 | | 8 | | 9 | 10이상 | | d | | 1.41 | 1.91 | 2.24 | | 2.48 | | 2.67 | | 2.84 | | 2.96 | | 3.08 | 3.18 | -![그림 131-1](<../pic/산림과임업기술(임도)_p131_1.png>) +![시공_그림5-2-84_보조기층두께설계곡선](<../pic/시공_그림5-2-84_보조기층두께설계곡선.png>) - ○ 포장슬라브의 두께 두께는 교통하중의 양과 크기, 보조기층의 성질, 기상조건, 재료 및 시공법 등에 따라 다르다. 표 5-2-48. 콘크리트판의 두께 @@ -375,9 +375,9 @@ 수 있도록 줄눈(Joint)을 설치한다. 줄눈은 그 위치, 기능, 구조에 따라 (표 5-2-49)와 같이 분류하고, 그 모형은 (그림 5-2-85)와 같다. 가로팽창줄눈 -![그림 132-1](<../pic/산림과임업기술(임도)_p132_1.png>) +![시공_그림5-2-85_콘크리트포장줄눈](<../pic/시공_그림5-2-85_콘크리트포장줄눈.png>) -세로줄눈(횡팽창목지)(종목지)가로수축줄눈(횡수축목지)(a) 평면도(b) 맹줄눈(c) 맞댄줄눈 그림 5-2-85. 콘크리트 포장의 줄눈 +그림 5-2-85. 콘크리트 포장의 줄눈 - - 세로줄눈 - ① 주로 휘어짐응력(Warping Stress)을 감소시켜서 종방향의 균열발생을 방지하기 위하여 설치한다. @@ -417,9 +417,9 @@ - ③ 최근에는 서로 맞물려서 교통하중을 분산시키는 포장구조로서 유효한 기능을 가지게 하는 인터록킹 블록(Interlocking Block)을 사용한다. - ④ 포장의 단면은 (그림 5-2-86)과 같이 블록표층, 안정층(Cushion), 보조기층, 노상의 4단계층으로 구성하고 시공법은 노상 약 1m두께로서 CBR 25이상, 기층(Base Course) 두께는 15cm 정도, 블록과 블록 사이의 간격은 2∼3mm로서 줄눈은 모래로 채우고 횡단물매는 2∼3%로 하고 노측 가장자리에 연석을 설치하여 포장을 보호한다. -![그림 134-1](<../pic/산림과임업기술(임도)_p134_1.png>) +![시공_그림5-2-86_블록설치방법](<../pic/시공_그림5-2-86_블록설치방법.png>) -채움재블록쿠션 90。90。 기층 0.8~1.7m 노상(a) 포장구조(b) 직선식 포장법(c) 원호식 포장법 그림 5-2-86. 블록설치방법 +그림 5-2-86. 블록설치방법 - ⑤ 블록포장의 장점은 다음과 같다. ㉠ 블록은 정확한 규격과 강도에 견디는 재료로서 값싸게 생산이 가능하다. ㉡ 포설이 용이하고 간단한 장비만으로 가능하고 시공즉시 교통을 개방할 수 있다. ㉢ 지형이 불리한 곳(급경사지, 산악지도로)에도 포장이 가능하다. ㉣ 줄눈으로 인하여 표층의 균열이 없고 파손부위를 신속하고 간단하게 보수할 수 있으며 유지보수비가 저렴하다. ㉤ 침하가 큰곳에 대해서는 기존 강성포장 보다 적응성이 우수하고 미관이 수려하다. ㉥ 중차량의 가속, 회전, 정지 등에 의한 횡방향 전단(剪斷)에 높은 저항을 가진다. #### 6) 자갈도 @@ -428,9 +428,9 @@ - ③ 표층부의 자갈이 갖추어야 할 성질은 다음과 같다. ㉠ 교통하중으로 파쇄, 마멸되지 않을 것 ㉡ 풍화작용에 대한 저항력이 클 것 ㉢ 입도가 적당하여 공극을 극대화할 수 있는 배합이 될 것 - ④ 시공법은 다음과 같다. (그림 5-2-87 참조)㉠ 표면공법(表面工法) : 표층저면을 수평으로 2∼3층 나누어 시공한다. ㉡ 상굴공법(箱掘工法) : 동일두께 단면으로 2∼3층 나누어 하층은 대립(12∼40mm), 상층은 소립(3∼12mm)의 자갈을 사용한다. -![그림 135-1](<../pic/산림과임업기술(임도)_p135_1.png>) +![시공_그림5-2-87_자갈도시공법](<../pic/시공_그림5-2-87_자갈도시공법.png>) -노폭노폭15-20cm20-25cm1m1m4% 물매4% 물매노견(a) 표면공법(b) 상굴공법 그림 5-2-87. 자갈도의 시공법 +그림 5-2-87. 자갈도의 시공법 - ⑤ 노면의 저헝력이 약하므로 다음과 같이 수시 보수를 한다. ㉠ 노면의 평활성 유지(횡단물매 3∼5%)㉡ 바퀴자국의 되메우기 ㉢ 손실재료의 보충 ### 라. 구조물 공사 @@ -444,9 +444,9 @@ ##### 나) 하부구조(Under Structure) - ① 일반적으로 교대와 교각의 조합으로 이루어져 있다. ㉠ 교대 : 교량양단에 설치되는 구조물로서 교대배면(橋臺背面)의 토압과 상부로 부터의 연직하중을 기초지반에 전달한다. ㉡ 교각 : 상부구조가 2경간(徑間)이상일 경우 교량양단의 교대사이에 있는 교항을 지지하기 위한 각주(脚柱)로서 상부로 부터 하중을 기초지반에 전달한다. -![그림 136-2](<../pic/산림과임업기술(임도)_p136_2.png>) +![시공_그림5-2-88_교대와교각구조_01](<../pic/시공_그림5-2-88_교대와교각구조_01.png>) -![그림 136-1](<../pic/산림과임업기술(임도)_p136_1.png>) +![시공_그림5-2-88_교대와교각구조_02](<../pic/시공_그림5-2-88_교대와교각구조_02.png>) ABAaaBCDC(a)교대(b)교각 그림 5-2-88. 교대와 교각의 구조 @@ -456,7 +456,7 @@ ABAaaBCDC(a)교대(b)교각 그림 5-2-88. 교대와 교각의 구조 - ① 교대의 구조는 (그림 5-2-88(a))와 같이 보를 지지하는슈(shoe)가 위치하는 교좌부(A),a-a선 상단에 토압이나 상재하중으로 부터 오는 압력을 지지하는 흉벽 또는 파라피트(B), 상부에서 전달되는 하중과 배면토압을 지지하는 몸체(C), 하중을 지반으로 전달하는 기초(D)로 이루어진다. - ② 교대의 종류는 (그림 5-2-89)와 같으며 적정설치높이는 지반 지지력이나 응력에 따라 차이가 있다. -![그림 137-1](<../pic/산림과임업기술(임도)_p137_1.png>) +![시공_그림5-2-89_교대의종류](<../pic/시공_그림5-2-89_교대의종류.png>) 그림 5-2-89. 교대의 종류 @@ -465,15 +465,15 @@ ABAaaBCDC(a)교대(b)교각 그림 5-2-88. 교대와 교각의 구조 - ① 교각은 (그림 5-2-88(b))와 같이 상부구조의 지점이 되는 슈(Shoe)를 앉히는 교좌부(A), 상부하중을 기초로 전달하는 몸체(B) 및 기초(C)로 구분된다. - ② 교각의 종류는 (그림 5-2-90)과 같다. -![그림 138-1](<../pic/산림과임업기술(임도)_p138_1.png>) +![시공_그림5-2-90_교각의종류_01](<../pic/시공_그림5-2-90_교각의종류_01.png>) -![그림 138-2](<../pic/산림과임업기술(임도)_p138_2.png>) +![시공_그림5-2-90_교각의종류_02](<../pic/시공_그림5-2-90_교각의종류_02.png>) ⒞ 구주식 ⒟ T형 ⒜ 중력식 ⒝ 벽식 -![그림 138-4](<../pic/산림과임업기술(임도)_p138_4.png>) +![시공_그림5-2-90_교각의종류_03](<../pic/시공_그림5-2-90_교각의종류_03.png>) -![그림 138-3](<../pic/산림과임업기술(임도)_p138_3.png>) +![시공_그림5-2-90_교각의종류_04](<../pic/시공_그림5-2-90_교각의종류_04.png>) ⒠ 라멘식 ⒡ 아치식 ⒢ V형 그림 5-2-90. 교각의 종류 @@ -487,17 +487,17 @@ ABAaaBCDC(a)교대(b)교각 그림 5-2-88. 교대와 교각의 구조 |---|---| | | (b) 방사형 사장교 | -![그림 139-2](<../pic/산림과임업기술(임도)_p139_2.png>) +![시공_그림5-2-91_교량의종류_01](<../pic/시공_그림5-2-91_교량의종류_01.png>) -![그림 139-1](<../pic/산림과임업기술(임도)_p139_1.png>) +![시공_그림5-2-91_교량의종류_02](<../pic/시공_그림5-2-91_교량의종류_02.png>) -![그림 139-3](<../pic/산림과임업기술(임도)_p139_3.png>) +![시공_그림5-2-91_교량의종류_03](<../pic/시공_그림5-2-91_교량의종류_03.png>) ㈀ 교면위치에 따른 교량 ㈁ 케이블형상에 따른 사장교 -![그림 139-5](<../pic/산림과임업기술(임도)_p139_5.png>) +![시공_그림5-2-91_교량의종류_04](<../pic/시공_그림5-2-91_교량의종류_04.png>) -![그림 139-4](<../pic/산림과임업기술(임도)_p139_4.png>) +![시공_그림5-2-91_교량의종류_05](<../pic/시공_그림5-2-91_교량의종류_05.png>) (a) 단순항교(a)(b)(b) 연속항교 ㈃ 랭거교(c) 게르바항교 ㈂ 지지조건에 따른 항교 ㈄ 로제교 @@ -510,15 +510,15 @@ ABAaaBCDC(a)교대(b)교각 그림 5-2-88. 교대와 교각의 구조 | | ⒪ 타이드 아치교 | | ㈅ 각종형식의 라멘교 | | -![그림 139-7](<../pic/산림과임업기술(임도)_p139_7.png>) +![시공_그림5-2-91_교량의종류_06](<../pic/시공_그림5-2-91_교량의종류_06.png>) -![그림 139-6](<../pic/산림과임업기술(임도)_p139_6.png>) +![시공_그림5-2-91_교량의종류_07](<../pic/시공_그림5-2-91_교량의종류_07.png>) -![그림 139-8](<../pic/산림과임업기술(임도)_p139_8.png>) +![시공_그림5-2-91_교량의종류_08](<../pic/시공_그림5-2-91_교량의종류_08.png>) -![그림 140-1](<../pic/산림과임업기술(임도)_p140_1.png>) +![시공_그림5-2-91_교량의종류_09](<../pic/시공_그림5-2-91_교량의종류_09.png>) -(a) Pratt truss(하로식)(b) Pratt truss(상로식)(c) Parker truss(d) Howe truss(e) Warren truss(f) Warren truss(g) duoble Warren truss(h) K truss(i) Pettit truss(j) Baltimore truss(k) Fink truss(l) Rhombic truss(ㅈ)각종형식의 트러스교 그림 5-2-91. 교량의 종류 ㉢ 라멘 구조 : 라멘교 ㉣ 아치 구조 : 일반아치교, 타이드아치(Tied Arch)교, 랭거(Langer)교, 로제(Lohse)교 등 ㉤ 트러스 구조 : 프랏트(Pratt) 트러스, 하우(Howe) 트러스, 와렌(Warren)트러스, 발티모어(Baltimore) 트러스, K 트러스 등 ㉥ 케이블 구조 : 사장교(Cable Stayed Bridge), 현수교(Suspension Bridge). +그림 5-2-91. 교량의 종류 ##### 라) 하중 - ○ 하중의 종류 @@ -564,9 +564,9 @@ $$ 옹벽(retaining wall)은 절토·성토를 사면의 기울기가 흙의 안식각보다 클 경우에 토압에 저항하여 흙의 붕괴를 방지하기 위하여 시설하는 구조물로서 형상이나 역학적 특성에 따라 (그림5-2-92)와 같이 구분한다. -![그림 142-1](<../pic/산림과임업기술(임도)_p142_1.png>) +![시공_그림5-2-92_옹벽의종류](<../pic/시공_그림5-2-92_옹벽의종류.png>) -지표면 끝단 앞면 뒷채움배면뒷굽앞굽(a) 중력식 옹벽(b) 반중력식 옹벽(c) 비자립형 옹벽 벽체 저판(확대기초)(앞판과 뒷판의 총칭)뒷굽 앞굽 뒷판 앞판(d) 역T형 옹벽(e) L형 옹벽(f) 역L형 옹벽앞부벽뒷부벽(g) 뒷부벽식 옹벽(h) 앞부벽식 옹벽 그림 5-2-92. 옹벽의 종류 +그림 5-2-92. 옹벽의 종류 ##### 나) 옹벽의 적용기준과 경제성 @@ -581,15 +581,15 @@ $$ | 버팀벽식 옹 벽 (부벽식) | | •6m이상 | •구체의 콘크리 트량은 켄티레 버식 옹벽에 비하여 적을 수도 있으나 시공에 난점이 있음 | • 기초지반이 좋지 않은 경 우에 이용되 는 예는 있음 (저면반력은 비교적 적음 다) | •높이,기초의 조건에 따라 경제성이 좌 우됨 | | 기타의옹벽 (특수옹벽) | 지형조건, 지반조건, 환경 조건 및 각종의 제약 조건 등에 따라 적당히 채용됨 | | | | | -![그림 143-1](<../pic/산림과임업기술(임도)_p143_1.png>) +![시공_옹벽하중도_01](<../pic/시공_옹벽하중도_01.png>) -![그림 143-2](<../pic/산림과임업기술(임도)_p143_2.png>) +![시공_옹벽하중도_02](<../pic/시공_옹벽하중도_02.png>) -![그림 143-3](<../pic/산림과임업기술(임도)_p143_3.png>) +![시공_옹벽하중도_03](<../pic/시공_옹벽하중도_03.png>) -![그림 143-4](<../pic/산림과임업기술(임도)_p143_4.png>) +![시공_옹벽하중도_04](<../pic/시공_옹벽하중도_04.png>) -![그림 143-5](<../pic/산림과임업기술(임도)_p143_5.png>) +![시공_옹벽하중도_05](<../pic/시공_옹벽하중도_05.png>) ##### 다) 하중 @@ -616,7 +616,7 @@ $$ - ① 사면어깨(산마루) 배수시설 : 산마루 측구, 감쇄공(energy dissipater) 등의 배수시설 - ② 배수구 및 배수관 : 집수정, 배수구, 배수관 및 맨홀 등의 배수시설 -![그림 145-1](<../pic/산림과임업기술(임도)_p145_1.png>) +![시공_그림5-2-93_임도배수의종류](<../pic/시공_그림5-2-93_임도배수의종류.png>) 그림 5-2-93. 임도배수의 종류 @@ -678,7 +678,7 @@ $$ 단방향에 따라 설치하는 배수구로서, 측구의 형상과 구조는 배수량, 경제성, 교통에 대한 안정성 등에 따라 정한다. -![그림 149-1](<../pic/산림과임업기술(임도)_p149_1.png>) +![시공_그림5-2-94_막파기측구](<../pic/시공_그림5-2-94_막파기측구.png>) 5003003001:21:21:45001,6501,700(b)(a)그림 5-2-94. 막파기 측구(단위:㎜) @@ -690,7 +690,7 @@ $$ - ○ 붙임측구 - - (그림 5-2-95)와 같이 측구바닥의 세굴을 방지하기 위하여 떼, 조약돌 등을 편평한 곡면으로 붙이거나, 바닥과 측면 또는 측면을 돌쌓기, 블록쌓기로 V형이나 사다리꼴형으로 설치한다. -![그림 149-2](<../pic/산림과임업기술(임도)_p149_2.png>) +![시공_그림5-2-95_붙임측구](<../pic/시공_그림5-2-95_붙임측구.png>) (a) 때붙임 측구(b) 조약돌붙임 측구(c) 돌쌓기 측구 그림 5-2-95. 붙임측구 @@ -700,9 +700,9 @@ $$ - - 프리케스트 제품은 (그림 5-2-96)의 우측표와 같이 U형(KS 4016, 4017), L형(KSF 4005)으로서 한국공업규격으로 시판되고 있다. - - L형은 하중으로 손상되기 쉬우므로 측구 밑을 충분히 다지거나 필요에 따라서는 콘크리트기초공을 하여야 하고, U형 측구는 측압에 대한 저항성이 적으므로 측압이 있는 곳에서는 사용에 주의를 하여야 한다. -![그림 150-1](<../pic/산림과임업기술(임도)_p150_1.png>) +![시공_그림5-2-96_콘크리트측구](<../pic/시공_그림5-2-96_콘크리트측구.png>) -(단위:㎜)aa 최대 최소 20R1:201:2020R350a25020Rg20Rg175b155(a) L형 측구(단위:㎜)a 최대 최소 a600150bb540140c45。c600150(b) U형 측구 그림 5-2-96. 콘크리트 측구 +그림 5-2-96. 콘크리트 측구(단위: mm) - ○ 횡단배수구 - - 암거 @@ -715,21 +715,21 @@ $$ - ④ 가장 간단한 개거의 설치방법은 말구가 약 10cm 내외의 중경목 통나무 2개를 꺽쇠와 말뚝으로서 고정시키며, 이 통나무 사이의 폭은 통나무 하나 크기 정도로 한다. 이때 이 사이에 들어가는 통나무는 임도가 이용되지 아니할 경우에는 제거하여 임도 노견 위에 두도록 한다. 최근에는 조립식이나 규격화된 횡단구가 일반화되고 있다. - ⑤ 개거를 설치할 경우에는 임도를 통행하는 차량이 안전하고 통행차량에 의하여 파손되지 않음은 물론 유수를 안전한 곳으로 배수할 수 있어야 한다. -![그림 151-1](<../pic/산림과임업기술(임도)_p151_1.png>) +![시공_그림5-2-97_횡단배수구와측구배치](<../pic/시공_그림5-2-97_횡단배수구와측구배치.png>) 유수방향집수정측구횡단구횡단암거(개거)유수방향 노면 노면노표면수 그림 5-2-97. 횡단배수구와 측구의 배치모형 -![그림 151-2](<../pic/산림과임업기술(임도)_p151_2.png>) +![시공_그림5-2-98_암거의모형](<../pic/시공_그림5-2-98_암거의모형.png>) 노면노면 그림 5-2-98. 암거의 모형 -![그림 151-3](<../pic/산림과임업기술(임도)_p151_3.png>) +![시공_그림5-2-99_개거의종류와모형1](<../pic/시공_그림5-2-99_개거의종류와모형1.png>) -(a)조립식횡단구 구조화된횡단구 강제U자형횡단구(b)D식철근콘크리트횡단구(c)N식철근콘크리트횡단구 콘크리트뚜껑 또는 그레이팅(d)목제횡단구(e)콘크리트개거 그림 5-2-99. 개거의 종류와 모형(1)원목꺽쇠 +그림 5-2-99. 개거의 종류와 모형(1) -![그림 152-1](<../pic/산림과임업기술(임도)_p152_1.png>) +![시공_그림5-2-100_개거의종류와모형2](<../pic/시공_그림5-2-100_개거의종류와모형2.png>) -원목꺽쇠 2286810cm10cm1512238 잔자갈 810 8 잔자갈 잔자갈 9½ 파이프 2610101075×209275×307.57.57.5~16.288.51821.5854596.6768401120422091513.21513.22037그림 5-2-100. 개거의 종류와 모형(2) +그림 5-2-100. 개거의 종류와 모형(2) - ③ 세월공 도로를 횡단하여 설치하는 호상(弧狀)의 유로로서 가능한 한 호의 길이를 길게 하여 차량의 @@ -748,9 +748,9 @@ $$ - ○ 맹암거(盲暗渠)는 용수량이 많은 곳이나 여러개의 맹거가 합류된 곳에 구멍 뚫린 관을 넣어 집수배수하는 구조물로서 배치형태는 W형과 화살형이 있다. - ○ 맹거대용으로 돌망태, 포러스(Porus)콘크리트관, 합성수지, 네트(Net)관 등이 있으나 현지에 적합한 것을 선정한다. -![그림 153-1](<../pic/산림과임업기술(임도)_p153_1.png>) +![시공_그림5-2-101_사면배수시설](<../pic/시공_그림5-2-101_사면배수시설.png>) -비탈면소일시멘트 또는 콘크리트 비탈면 산마루 측구 비탈면 소단 노면(a) 산마루 측구(b) 소단 배수구(c) 맹거(d) 맹암거 그림 5-2-101. 사면배수시설 +그림 5-2-101. 사면배수시설 ##### 사) 지하배수 - ① 보조기층 및 노상의 지하배수 @@ -763,9 +763,9 @@ $$ - ③ 옹벽뒷채움의 배수 - ○ 옹벽뒷면에 물이 고이면 흙의 함수량이 증가하고 단위중량이 증가하여 내부 마찰각과 점착력이 감소하며, 점성토에서는 함수팽창을 일으키고 토압이 증대함은 물론 기초판 저면에서는 지반지지력이 저하된다. -![그림 154-1](<../pic/산림과임업기술(임도)_p154_1.png>) +![시공_그림5-2-102_돌망태사용예](<../pic/시공_그림5-2-102_돌망태사용예.png>) -(a)보통돌망태를 성토비탈기슭에 사용한 예(b)이불돌망태를 성토비탈기슭에 사용한 예정면도 측면도(c)절취비탈면에 맹거와 돌망태를 사용한 예 그림 5-2-102. 돌망태의 사용(예) +그림 5-2-102. 돌망태의 사용(예) - ○ 이를 막기 위하여 옹벽뒷면에 호박돌 또는 깬 호박돌 등을 채워 배수층을 만들고 여기에 집수된 물을 비닐관 등으로 만든 수발공에 따라 옹벽전면으로 배수한다. - ○ 수발공은 무근, 철근콘크리트 옹벽에서는 뒷면 뒷채움돌 하단에 약 5m 간격으로 지그재그(Zigzag)식으로 설치하고, 돌쌓기와 콘크리트 브록쌓기에서는 2∼3㎡에 1개소씩 설치한다. diff --git a/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md b/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md index 1d52a92e..9441d3d0 100644 --- a/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md +++ b/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md @@ -59,7 +59,7 @@ 일반적으로 채석허가면적은 채석면적(채석장, 산물처리장, 기타)과 부대시설면적(진입로, 관리사, 기타)으로 구성되며, 채석적지에서의 각 부위별 명칭은 다음과 같다 -![그림 159-1](<../pic/산림과임업기술(임도)_p159_1.png>) +![복구_그림5-3-1_채석적지부위별명칭](<../pic/복구_그림5-3-1_채석적지부위별명칭.png>) A : 벌채구역(허가구역에 포함)B : 돌림배수로 C : 절암(절취) 벽면의 (중앙부위의) 비탈어깨(法肩)D : 절암벽면의 비탈밑(法尻)D′: 본래사면의 비탈밑(法尻)E : 절암벽면의 비탈길이(사면장)E′: 절암벽면의 비탈(수직)길이(수직에 가까울 때)F : 절암벽면의 수직부위의 밑점G : 절암벽면의 수직높이 H : 절암벽사면(잔벽사면 : 채석적지 사면)의 사면적 I : 절암벽 수직면의 단면적(거의 수직으로 채석할 때) J : 절암벽 사면, 수직면, 밑면이 형성하는 채취 후 잔치된 토석체적 계산용 단면적 K : 본래 산지표면 L : 표토제거후 암반면M : 표토제거깊이N : 채취(채석장)부위 밑면길이(편평하게 된 지역)O : 토사채취량 계산용 구적도 단면적 α: 채취후 절암벽면과 밑면(수평선)에 의하여 형성되는 잔벽의 경사도 β: 본래 산지표면의 경사도 P : 절암벽면(E)과 수직벽면에 의하여 형성되는 삼각형의 밑면길이 Q : P 부분의 평면적(Pm × Sm = Qm2)R : N 부분의 평면적(Nm × Sm = Rm2)S : 비탈밑(D) 부위의 (좌에서 우까지) 전체길이 T : 산물처리장의 비탈끝 U : 산물처리장의 퇴적부위길이 V : 산물처리장의 평면적(Um × Sm = Vm2)W : 퇴적토사단면적(폐석 또는 퇴적토사)X : 산물처리장의 퇴적토사지의 사면적 그림 5-3-1. 채석적지 부위별 명칭 @@ -158,15 +158,15 @@ A : 벌채구역(허가구역에 포함)B : 돌림배수로 C : 절암(절취) - - 절·성토비탈 안정·녹화공사 •힘줄박기 •식생공법 •격자틀 붙이기 •분사식 씨뿌리기 •앵커박기 •종비토 뿜어붙이기 •주입공사 •새집공법 •낙석방지망 덮기 •식생상공법 •낙석저지책 세우기 •소단상 객토식수공법 •콘크리트 뿜어붙이기 •차폐수벽공법 - ○ 공종(공법)별 부호견취도 작성시 공종표시 부호는 다음과 같이 표시한다. -![그림 164-2](<../pic/산림과임업기술(임도)_p164_2.png>) +![복구_그림5-3-2_주요재료의부호](<../pic/복구_그림5-3-2_주요재료의부호.png>) 그림 5-3-2. 주요 재료의 부호 -![그림 164-1](<../pic/산림과임업기술(임도)_p164_1.png>) +![복구_그림5-3-3_주요공종의부호_01](<../pic/복구_그림5-3-3_주요공종의부호_01.png>) -![그림 165-1](<../pic/산림과임업기술(임도)_p165_1.png>) +![복구_그림5-3-3_주요공종의부호_02](<../pic/복구_그림5-3-3_주요공종의부호_02.png>) -![그림 166-1](<../pic/산림과임업기술(임도)_p166_1.png>) +![복구_그림5-3-3_주요공종의부호_03](<../pic/복구_그림5-3-3_주요공종의부호_03.png>) 그림 5-3-3. 주요 공종(공법)의 부호 @@ -198,11 +198,11 @@ A : 벌채구역(허가구역에 포함)B : 돌림배수로 C : 절암(절취) - ○ 하부에 설치하는 소단의 너비는 상부에 설치하는 소단의 너비보다 넓게 해서 상부의 물매보다 하부의 물매를 완만하게 하며 잔벽의 높이가 높을 경우 상부로 갈수록 소단간 높이를 하부에서 보다 낮게 조성하는 것이 안정녹화 공사시공에 효과적이다. -![그림 168-1](<../pic/산림과임업기술(임도)_p168_1.png>) +![복구_그림5-3-4_쇄골재채석장잔벽모형](<../pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png>) 잔벽의 평균 기울기 그림 5-3-4. 쇄골재 및 토목·매립용 채석장(계단식 채굴)에서의 잔벽설정 모형도 -![그림 168-2](<../pic/산림과임업기술(임도)_p168_2.png>) +![복구_그림5-3-5_원석채석장잔벽모형](<../pic/복구_그림5-3-5_원석채석장잔벽모형.png>) 잔벽의 평균 기울기 그림 5-3-5. 건축 및 수출용 원석 채석장(수직벽 및 계단식 채굴)에서의 잔벽설정 모형도 @@ -256,11 +256,11 @@ A : 벌채구역(허가구역에 포함)B : 돌림배수로 C : 절암(절취) | 중복부 | ·땅속흙막이 ·단끊기 ·돌림수로 ·선떼 붙이기 ·흙막이 ·조공 ·줄떼 다지기 ·새심기 ·파식 | ·심줄심기 ·땅속흙막이 ·단끊기 ·돌림수로 ·산비탈 돌쌓기 ·흙막이 ·선떼 붙이기 ·조공 ·줄떼 다지기 ·새심기 ·파식 | | 하단부 | ·기슭막이 ·산비탈 돌쌓기 ·수로공 ·선떼붙이기 ·흙막이 ·줄떼 다지기 ·새심기 ·파식 | ·기슭막이 또는 축대벽 ·산비탈 돌쌓기 ·수로공 ·선떼 붙이기 ·흙막이 ·줄떼 다지기 ·새심기 ·파식 | -![그림 172-2](<../pic/산림과임업기술(임도)_p172_2.png>) +![복구_그림5-3-6_채석잔벽안정녹화정면도](<../pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png>) 그림 5-3-6. 채석잔벽 안정·녹화공사 개념도 (정면도) -![그림 172-1](<../pic/산림과임업기술(임도)_p172_1.png>) +![복구_그림5-3-7_채석잔벽안정녹화측면도](<../pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png>) 그림 5-3-7. 채석잔벽 안정·녹화공사 개념도 (측면도) @@ -325,7 +325,7 @@ A : 벌채구역(허가구역에 포함)B : 돌림배수로 C : 절암(절취) - - 석재가 많은 지역에서는 떼대신 석재로 단(段)을 쌓으면서 돌 사이에 떼를 넣어 돌과 떼가 일체가 되도록“돌·떼단쌓기"를 시공하는 것이 경제적이고 효과적이다. - - 떼를 구하기 어려운 장소에서 떼 대신 볏짚을 위 아래로 구부려 넣고 단 위에는 묘목을 심고 풀씨를 파종하는“볏짚 단쌓기"를 한다. -![그림 176-1](<../pic/산림과임업기술(임도)_p176_1.png>) +![복구_그림5-3-8-9_떼단및볏짚단쌓기](<../pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png>) <측면도> <측면도>그림 5-3-8. 떼단쌓기 그림 5-3-9. 볏짚 단쌓기 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png new file mode 100644 index 0000000000000000000000000000000000000000..920fb364db78515428477fed9661532e5206cf47 GIT binary patch literal 32424 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 Vab)x-96gVwsj8>)O360j{{XVv2A2Q; literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png new file mode 100644 index 0000000000000000000000000000000000000000..32125ba40e8c45c369f7c006d51bda97abdcc599 GIT binary patch literal 33279 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png new file mode 100644 index 0000000000000000000000000000000000000000..00f3caf95d83161c9e857c1344ca229c1916e940 GIT binary patch literal 25478 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 B=Ue~) literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png new file mode 100644 index 0000000000000000000000000000000000000000..fc13bd6b52516053e371f1f340189ce79365499a GIT binary patch literal 26956 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png new file mode 100644 index 0000000000000000000000000000000000000000..f1cbd166123c697790b541c8dbcdf789e7684608 GIT binary patch literal 8909 zcmYjX2{e>{)J7pomJr646h(HEXc%ikw#bq#OA!^aP1&-mW~hkBnie6-zKmpPkiBe` zY_E`gP^O6QPX6EdzB$L?yfgE@zu&#jeeUzzpWCL!dV85s%rrDKd-YH2oQ2;TG&Hnl z_t3##wSMX@@Qca&w2dzf4O=tyhxVZqn*a^X!7zOtO$%K5LdFdX?v|Q&j4_-xu|}?( zW*i(mJi|pBr`{2=HI1B2HT8)G%~iTY89{l@Iv*x^lP4{6X!$|34*pIR!+SnjT^%1L zz1yhDDBaI8t-sz5{(NrY7Q>(3W7N48T#r5Rt=~T{Ep>D)*wNp={3)jr0}T+S3NN1k}#gMq->0Czjf=*T=-hq zl=IU31#=%^cGg20AFHmo#zpUN#)tNHLqo$XY?!fRR8&-7Umu6q_OHr%rar>< z)_T1==HQ7-moD+}@EE9u2X}RKoqgHY+S=OK==>q+sA<)u&a}x%S0&-zpyA=+BtMk$ zHNoCGlPqi+~z178)1Kbyci!JPmy&zg>crz>Y#uB{!J#6Q&Ur4XCx(+-TXPrBW3y8 z-`UwY?y@`P$vOwZE_j0G>+RdO3oTZsI$h=xPmrC)2!j=bxW*Hi)Qiw03-ayutGva@BX&Mz7RKkqf zgegyqx~6%)Vx9gh`>vk(>*z9Q;c zB5mPsRYaqB}$k;_?O1J6uf#xAfV!mxLBT3#zx;e6m!tP!fJ_T3?E8a z9`Eb37y{&FHhYsNbgAo3Q&N|clhdFTm>#pDOLxTNGP1gXEjpNb0i<0GxD zd4Mk#UKzX6OM7>(pM7bOeJz`OS}Laj=i-8x<(~3sCksM`QTWQz()RDB=g;4PMRw0M zlXrL6amv3#Wo2Z9EPN>!CoFxdUDp!{NlHwNgin|xN-2f>{+=>7@XBRwOfonQ=l+qB zmzVeYwcLr>#l_H>9??A$y*Qmi?$*)W0|URNIv?A8qNc-%AS)~DmUXWw-ni!aiR+Vl zc0z1#{|(aK!51%rRmUw~5EB!7=5Ybrr%6SBVVY5e^s!?fKYo07#L>|)C@AP!`9mqI ziIAc=zOf>G5{VqRI9wgDfI3=MQZn1%J<-gx6Q4|>gvyEv2iUnMiuxEMlnCTQNJxmE zpNVO1`v|-=b}%?t?8qLz(HPBAjrK{VJ;

hmIN>8>`-wIdPN``k$2Y{O~VOGR;~`Q$Fp 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 z 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png new file mode 100644 index 0000000000000000000000000000000000000000..2ac0e3421d7cc83316b32810df1f25583a33d35b GIT binary patch literal 25231 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png new file mode 100644 index 0000000000000000000000000000000000000000..d7a2a468e9f464bae16677ef0ff1137157e19b70 GIT binary patch literal 19552 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png new file mode 100644 index 0000000000000000000000000000000000000000..24395d654d8662d7aaa25e571d442c180811754a GIT binary patch literal 12890 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 OJL<~XN^cab!v7!gvGN-L literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png new file mode 100644 index 0000000000000000000000000000000000000000..8eeb7b416b3eb4e5e5ff7311e4906f297fa88490 GIT binary patch literal 11666 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 TERqtItdZ`j+*dABv#PVom$S33Y~n0N-~F1 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 anJhlvG#^y2R!d^RkC~B`;T?U~u>Sy!c*^Yn literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png new file mode 100644 index 0000000000000000000000000000000000000000..ac493210a20ea0bae10584944d21afc2bbea6a66 GIT binary patch literal 14894 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png new file mode 100644 index 0000000000000000000000000000000000000000..4fed8da9b63f23118018f7ecd62453a2419d52e4 GIT binary patch literal 16514 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 ^LU%+t=%Ayu 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 Q6}YA#qas}@X&m(b02fWgApigX literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png new file mode 100644 index 0000000000000000000000000000000000000000..943f73e3d134dc51904b67dc87b5bca896b02240 GIT binary patch literal 26292 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png new file mode 100644 index 0000000000000000000000000000000000000000..63f411e91d0804685daac388a3a6c5ceb15d9491 GIT binary patch literal 22603 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 Ws3p5o!{Xr%tmNUc+4%}PFzr8fqjX9D literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png new file mode 100644 index 0000000000000000000000000000000000000000..242cd40be5a4e7689840c3389955bb1a567ccaa8 GIT binary patch literal 21585 zcmX`T2RzmP`v!a*d+)tRMrJ6xgUFsK%9cb)Mr7sKTV|oih*BC>A8SnQ!uIsw5`=r@gn=ms7FrZK(&O?RcU$rqTb!~i zawKhQwxjSSkF2aYY7o1R`}ooI)s=}m@{UDnYinzlhfC5jGUWSR>}*q6;h)3*{Bo@} zzr)1L%*eoiE5;%RHwpN8Nt4?OvzhQC^zb7nMBeemvllPkdG#@@RApzgE@O<1DV8x& zvB=rlbIkPgpT@_BhKHqPZr!>ihsYgekVU>yk{oSqaUVYX_vH&K1A~N2f0m#L(ZRw+dxN-hN&9nbybe5N0$y|*L;Tus$>RF;Ex-RBrLADP z6MK7mo3GC{M9KPQ?;TfHXA#lGDN!>p&=epW1_WetN@Hhtp)Z4hf#KJ$UwzxipRL)J zMC_9bua1%%^6AaY%-95uUl~tLOYh=n;gaD@wsie@kV z_t>V#sYElV(#VEH*nar(<@HkS&__09!%W(hk4-FU~hUdiz$-Fmr4~E~!0s zr(;8(P?nn;wD-e~3O%RB5=b7cIOXHxGdw)pcW^N#hCEq+F*Gz(QbJ--sf{ZbLhS{C zeDJSW?ef^z7&%<#Awuu-@!mwa0j26C&66kHsF2;AozL(}BX(Dm-QSr?jc{>M6(BCT zMMZR5-{JdhH@DK?ory*IvqfLpS2GZ@7in2VQs*%E#r|w8nP|d6tS%>DZOI{hiS(RD zx98^Oa_-$bx?oK~zW)8q*)wPIg^*l?i>sOfUI_}}=HhyK?)in*;7c+xk3SSD!{ImL zbh;4up25%6l@)0j8D}S_y{*|Ym!o+|s=~_0`x@c+Is1>8B;ACaGWkv{GjmpKEXvW* z(J2nzNS276ww#<9!5Tl?*;yX>`zITt3P0R&low9MCIhYk?JIIz_M&27NIQI6R0P(lM~`^4w0JA^ z_rx&qP%6|y82O!tZt7bt*Mg)nBt6tY1ekjYaX5>X5nI#ye%l z17_OSmX;iv1HQj-Z95sYZz%(0|m1k9!7hd}l*ZzCFGo0%X3vVltm_3I;B3s;5 zfZx>2pwiO5_Nz8?o3iPNORFj}NmV2xEG#T4Dq8aFZ0%==`9M`AC6?yamX_EH6Sa=3 zULEF?kWsb{4ld0B!kLbdk#8z0=Et9$`Ji*g{{I51DOa%yU^HK|?L z*45P&VqGatLrx(xHPxxF=k>Lz7jAC{wS`G-4<3l@a*Ebm?7y9d^GG@j2`I8p9pWt3 zrkY>2wzeiG(-gCe*VsvVYS1COzrMGzik>WRi!pB+;8eyV#dDzm-?EGtg{*aNz z%Om(J@b6NXsQnEszcDF3_LBB!qN-Nw_1Tk?Cwc0#Rp7ODO})<&{8H@`O^YpSb#&soBii413IxzYRw@uG22B6$6bT zmtM)GC1>`ul#C2#8B;ZF#_#}ge>I_k65+-7&>hg;37Wy$o3%nQJ%W|0ee$H$??34s&i(3owxUiWR3c(43-y|XnR(*V zCj;LEI_j-gQGb5@`0?Z0tE-nUT?!frOgc>D9AXE2I|)g2NX8i8tV(o+$oDm_ySzVW=*a6JgE63-L{kmmiB zgOQMpgM!kAMaXQnlr&5ptJTzmvitWx%zC7CA3Js|*ECsP=zo>j@$A{}udecj&{)2U z`lIBl?;=5^mUuPj8dYp*X(^SwJH&2AR@Ua{=l6e55s9qFS^j(P@~t&QhLHUyiEZ-r zH9@Ub?*WdIUIQN==?6+)-b)aK6G#svQquLgFG$XM4(e1glQ_onMtf3h&0`!bJszW| z$lNzvv;oEN)a^7Ss`J$U%Wdf766eStK78oW3)}%1V!SguGvgM2Kceu)oq9}s8IqQk zhEws)?iJOG9IrCVAIO(kZ13p6xcqn<^{2d&-J*v|-}U~zdxp(DJSt=QsJd(&@+ouk zbUZV96MN>7rTAUyZ~!Dm;#^<;qaaJJq}2cW`&+&{|AAhVVQH?2c>(=`Wb1dEEXeU* zeuQ=64TPGeER=Cia)JteGVcoZ=-SbX*hKw*vp*+l=U>QgzvpEYJi(M)o}N%R&DOw^s zW1V%}jlw+ZsLB4(QQI(ADd@ozLkw$=#Kqz=+vDK0G>4HS&F02NP8J3sVd1aW2;wV8 zF9s(`0LV2ZjtC!Fz)U*^2k(4syiBxlYYD74oh87u%g!?X-1#O#KG@iiJp6HLS@uVZ zvGJk+gX|>8kE^O$T3Xbv&*-YDC^7@ z`02KrF9BSOA^9>kO0I9H4%}$BW|lHWqF$i>A09KbQP2U`~j-w9lM_wJp9&!U>CwRKo<@Kea%Kfk^& zhxrN^^B;DQ-uXjd*X2#XZae=TF2h{+@~W(@l`s9^XOE+SkXXnjA**HBu&tTrj~_c$ zdIl1SL~mc)T+WNKvQp^7ckvMca-yNC$}x!jFS{VsNQ@Ypz_Ay3OP+H*a&@(VBjBWI1 z3u**7!5?R53!;+;E+1l}s!HmcLl-J^4)8u9a7-&T11^|~ASxu}0_Vx2cmYDp#y&e7 zps~Yg_>R%WlJl=aqFHqE`G$ z3vcGR`JJOKpU1fLjf|*@dayK}9{^yUm!ac0;X69_d;JUibo{=Zz5UVPdySgmaO-e( zp5{d#Er6Z?Yx2K#%_LjonxEg=+?@Vvi|zz2Zf@}ww!q@z;$yX}7L?`5Vqb0baw5E+ zoq24%zrVj+DtCCl-4FuhL;uGxoosE{1O>19`cA%$y7~ERhvvrDM%v7Hh8%}M5B3s{ z4%4sDN@RPr4J0KW0(8n>gf^S-Rw8UXp*5OJipVW|pc{c!{YN0wJb7}Al%1AlckW#B ztG;aj!{SMhv5A%X#W}Z9po~>Mf6g4&sg0ls2mk)S39bwks{sJjjqg+NPc)QGgpA8C z^DcDoyV_MvT1{+e(F7FCM$duDjQ@4~*fI8cUbd^#Z$cTVRE{6FvarZEjmJHI^vDO0 zS&`bP!ZTv_73PTUXIG6*OmfthZWFc_k-zINC6x6d_k7{0G&}#Pl2}<;zl@D}J)llt zJj3`f7JYeNQ{Q)PVWC?IL8GSN=_;n2n*%mCHkb@Ff);x7q=Ny8aiPD8R_8Rc3o6_* zYLJ2RYovNAOt-kQk{mhG5uh1v8C&Why|E`E-Y-n(l`SMeD8Dzv^A=!jU8pw z4~3%NOH1B6oFiWKLhJA-cG5L6vL!$X1Ox?vKATEKaZoK|1W6MA+BP+pRA1Lb6L>nLZrYjTa3L#W!B0@qN z%gcQ=dl|ZOtur$-OGP!2o}G27B;}<6P8|8JqO(M|L}P`t&4ZyWJgbmqY+$S_nV6fG z7w;ImFjvGff2c(aJlg6>VU=-30g5kCy)>93e#)b6P*13wbeS@G-0tCNgXmvV&8(4k zG-;zh0H?Suq9^c*?T&MUjgciqh~#;)drKh2KxG&NR6`DLdGIP`gKNXI?BuN+XR1!g z)0s}M{l5Z>0^ns+qDgTw|9i~*ke&HbON)v@ECO6AZt7-maD4jdb3yM%Mpz9KS}E8m zW|H7_)`x9gi2f?kwvNA-mBhx*Zs)C?h(N_&9WDjDvDkoD&i{lR8dqz@jDp^K< zi79u4l*kb4aycgbAv}YqurRYVhXG5Bgu1DzY3xe(nWBH`XOPV3fAOiWC* z&M!m0zX=so^I;0{(_{sSDqNPKq~u0Oh$QC=;O4Ej{_TUj!d0lc^MovFTZeX>&(CO- zgeThPL8m=P6d%Gm$*1xjvQbA6?@s5uN4Pn9k)Fr&*?7q7n!9)J_7bzj_g(&f_v`z8NyGqStEZvCYmc7SJKwrD)qEXF?xp$|b_`Oekaz?j0-vlD3A##frY)-9STw`>|}lCi~4m(UVBwh(kD& z0?8zzM42I>1LPJ^K%x)+a?i6a3G#PkEp(gN*o>TS^tsdIblA{NKr<=L$gs!~IKIE} zH9s#e@7_K0r|d_k0U_fjXqouLzHZOht|>adF7W>++95{Um2>SjDaDG4J_xmX!n2#I zgvv8=?*|1-ohclr>=3);X??O-P)-^DYC>LfLxY6Qqx`NYyS*KE z^G!6d;rVk#@iEYUj@10Jm78kvgWSwNcO#xeiUzFx>#yjVSnZJc<5Y$Bw}s{7HpqVp zAp02$j>arVNj7@oJmL@KrK~LDL{w!Qm&dL6_;|54HZdb(o z8ZXJrWD3sIzX%9vBUn}rDl0|re2v{hf(g0vcvt7uD_8P{-CDY04*nYO@1$T|^!GZ9 z2&_D2(MtCMNj9S`<&Jp^RZZs);&LZ8gLzb$2U=N zA%u75%56E;(DlzryUGDxDt=LhH~q!pYPC5AfBm=@=e=lAO_UE)7wr^o-b z1YKn3j9Fq!HL#T))7mVfSjG;Z8gGGuOlN4c2*}jJ%*=1BirzmCKNLNQ#!HO=P;M-dGn12i6(?Cz{mN84(M7G1 zzpu>yWB(qyH6tux>+X)7eRoS?9}$ne<2Uh?yQ&u?63w?L(T2uGP{+@nJ)1G4l~e-i zDwY1^=NGOzHIX1NHayjkW#-}TM+|5DKSP5D3HN?+G4bEO#{Z@?0;cO;zdlVT*5#Lk za{`XU#l`ja&(=s#QgC`Yq(aa(RG0)>)^jghz77ti7xUk{d-wC3(5u|scOWyl|77#? z^8=vK3tmdi$oP=XEw8GkX0hi|l&KfBKl$w&kH&DK@X1?$$J=gR_vHj-%Erc~i0|`G zV&y{oj=MtVcHUZ$hedd}=3+`naIn4zH1wd?e;DcM0YM3F_2+&;V46FnQeD~GtV`qq@)zd z6OBzwdWxQfhB{A~)`}^a$qN1Ycm7Fcb56Vf1ntnSJkFK*yPIda> z-Pr_a!l9x8Q?H-HcXK${xN@euts;RcD5I(HZ1Kq89eF5&)&1BrXU?><+n>aebuIM) z`m-%2CMB6&aX}>&B-x)^RoavYWA-hD4@%yDqm#ArI@=A~P`VCj*P}q_)3({T}y*v3eYZXYV3T-#(rE6PQP=rSc5NPr1}u>CoCNun?cdaS%7GkvhY>WrwR018>_DVgxC<3SK(moK@KYu=l8FrRET560XRl4;c}OYYE@~LvFtb;P9OIj2yz&sDG$RM0=L># zrsmwWvk1%x)Iw%Dx>)f^Q678g$umA?@F{$Bs_ z>0wr8X3MiF$xf^D)bzYh$goxglLE zzT)EIvIxv7g2%pmDco_7*@h7yl*lVruS%*esm@r(BHpH~U$3VafS8Dj|BOcM!kJ7y zG+}j_n3&MJ@q_K(A32c%WDc|8@8}a&yE5H!tQTu`d5sZ8f|U3R zLoQ{P7B-;B$Feogcsz6-*OLCWO3P&8&lf2zz8QNozhrw6(pH$42$hI z*$~y!oOXQ69VKY<`wETC09I}vT${u6tZIk;>@3Da6Kv-mKn5ybXi8NO9h#lGe)Z}< zfc1HzMXXSEs3hCMHuDGs1nR0S8CmFpf!OS9M^5t&X1PNbooEux@)0TSst3@)!+If`5Z;H;*mmLR1SIEQM?qPhup4DX>1=2pSOSk(r`OQ7`-NW@)W z!-Lq?o(_b3zodi-OGjojTC1i|LEs`eWvrVT8yBymYe1j>^w5M_K-kXUqL&xI6aW#9 z&z!`ctmxNd1JZm_Tl+x^;df^Ju)evOrCLEM)amW-zkmP!AvTN*f1k+@32X(59Ks5+ zQ(h!n)e0ObkFp!nS=N;QfL+bac@R7=UtT?W_9-X?ysh2JWJY!ah=$+4f6tdYUBBAL zDz&h@T>b2s5EobC&BS$1zHYHyG16s$OG<9-HRa`VzkXR0m2VyvJGGx~#>U0~^kBd? zgUuO#ec~|5nVG3~2gEu6JIP2(8#1!}o}ZtOV{-`Cxj+LjKzcXzbS9rTn`9m8fuOK( z){lo_o8Q7sVBRqP54vLazM0Bkjt?6dd+_&;7A0ebS^*MFTXS_jEFAqKgnTRbJZdl5 z7gTs}XxkOabE^hgFeIYTAg_EB95?;6c{^2LuF3PHi;GJ`LxX03{0$Nzs25R*#&I0O z!sJ$3S~@j>jmm)U)Xh{R6(CzriCOgJ`Z3CROh;D|_h~I#@&|U?zL)MD!~SCmnNX=2 zp3R7#3=R&qtv&%7o&Tgt&8;F1HMK?%hpMWp>jQ>B(BN!= z(-T+5K~Q=Llc5z|K0!ga7va_b`Z}19>pL(!2H7Vd(9jxk)(Jw;;TZb*IFl*V^8^p`oIJ*>!x>)oz%S_H=jO z2n|(KP)ID~X6IDV*9qH*6+U0*(S<=s`MJ5dI7&L!Z_U@Hc%)Ioqu;*$#pOy?o;3q@ ze#0s{TKBfN5Z*o9Dy+3q_9E^$A|AwUmr!O)z{d?u>%`~_J`7HfNls4Ap$7?bH)68k z5@S3b-jc9ppvR|5Q7(3%%+X=SJJkBTY0wu?ncuErCLQ(Zi#>(^p7CqZ#Jr$FI+nC}a+>Hq<|U z^ym@H+;l}GF87YHb+()-qfPt)xIf8Vv6_Np8V1>F%Ts3#8dB83-_SI&u&R`-tmzE| z3zLBbG<4nukBFc)%BJhHF(Atg+3=aqf47yZ;@tUwWs3nudF@+MYs9W}(1$V%HULH> zJ%KInii(O1XU;|7>n+Hp{a;5c|$ zw1{QDs^I=k$dHCQzFFSBqbcBfw^Vaf*?=5kcjn;WfPDd& zVlhGjG4lDdXPwbM&;)hzaD_X<*y8?UX=7sp0;B?+n}5kY%l6jRbnD`eA2kmjataG8 zzWdCEC1GadL5A();Ng*uN3aLK9goad_wy>dNv(P^m}Chg1uIRY4Y|aQ=|t|yx?S5| z=-X*sjw{(IQ@=I!_3O4;dk(MTLqoB0YNEWkM!1{X4CIBSrKN&`!dz{ilylk&qVKDJ zg!>DXKEdX-mT{|ia<_rGIfGXU1d5_lUFXcqJMWhc=k(c@e*JPw)7W@}o8b2r*}-AQ zeKaj)k=|aTdGa*^1s`=>Yot9(%K=CzU!I&XF*d%0pX;g_#5zcDlpy^-uv(96M)3J8c-aWazSH(PIU2hw(OB|)x?yPFHg^AG!cIM{OR`Q#uKmNnA`9`Fpzrz z(WOJR9i6Dras$i7>ef~wq@D4!dn9T0O=#_-N7B3hefV%VWfl^(i)_InCxg`Hi(sf{^ke%Hyy7y*eWOQ;0mXnaEgpTjRp?r;v{N>sCMko+q@9 ziOv=z-wKKqgHrvQB%k^eFh3(yrS9+FgoWYS9f@mQ->| zvyp!v-#J}&L;b&k6J1yxoi=DnV_uN}#{h-q`Pl(51GH708ken#ikQdR+jE?3eDdT; z=Brc;pUM3_V#vQ6EHQgexO^t7-@1i1mb70 zUB>jje;*uSv=&D|faj<>Kj>l0$^C@G7<~j}E&t(^ z4Wl#Y*4NfHXFJN)kW)CyQ2i5<^SmdGki{5U+LW3aS<_jlZ$S@I(OkBD#ycIAu>=G( z-JOvS@#%5a{^K?5UvDdliIoe5I6ci_B%dPoFoWKA@BE6Z59;Xq{{BCpgw&&J8ld}o z<4C{?p^iYDc9B9izj4P4T^@d*w?02#QCVsD<{b#tPzr(WWw_j`F%`>&5pePS`!@Ga zT!R$hzR7-U0ni!+`yPl-fM+F*3>9K;D!kmdbHcj}neD)`GBG6<=GU`eW2zoMUjO-% zgPbq{av}Re*stc-6d8QiL=DguIhLw4uJRdx|d+p<~J@ znJz6Zj#%iW=g;6LiZ7B(j+d%lGXL+9m9py_sh$vBz`9^o`3$iMtSTirS+znT0YVm} z__NCuAQF+BGX(_&U!HG#KRYWbmlF}=_3KtnUS1{KSXI>x03r6_k5%J+$scsRi;>ay zfIUawf~Zqa|LG*!>0PweWJZU#Kpvi1v-sXU0Un-}A3sceUshKqEy5E+2A>*QU0U-0 z)?{|(45yETxVS+{iiDMFuoBpS#*`gkTvB-6`_d)AMPL#80bUW+4H9Dn$wcV72jf%ztGm3X z&4{e4%IzQY<~x#-`d~iL7a>cq*`T&G>l2m%_eMSvnRiTMAxBl)$;OF8r!$ROz8rTuXC-&6bG<~UM ztVx|gYEFL1Ogk711`8q_ofYVRN2fe7ln&D%xr@yZ7fZna19OR&=bj5}^ntgdYZovx z7W_WlNsQ;tovZR=^Nx+q5*z>=a*7ZTV_i^=rVkU zOX%gKRGykgn{M*RsrCWK*?^#hy2^R>%*qsr8kKI?lrhU)SXkH$6KYi|&|U-*uaA~f z@G}Yt2r#rlTz?042J`*=(=krzAVEw`PG$=0>;u%7y;4MZ>C!Ub^*bNK)~czJ5r9zM zWNfZ9YRe6H4$z!NK|`fi@b64={p=HnGO7b6q+IKo_$o?_gv0_6 z1ToUR<5%Z4gU0^*FMo*Fh$(4`hKaB9nQ|=nNWofS{pi5LOwT6^qGZn4<2NAH=-)rj zs>+z7RG7W5adTP2u)~wu{LNJSctEa%4k?8DWq&21G&(jG&no3yC8jPmRh^(4Eho4et1b}P|i0+d+$Vq zkPg5xQ73Qy$-PPn6XsP`PqQ#}_I@fAG8TfD^vA@+M3A3FEhb zM_ApLO)54vrKzqC1|s~j4)Z=vJX&Ph_#a>|lI8RbZMe%00AvuXnS#f!7|ldNjRd00 zeR&b}C-lV&*jp>;2!uo?<6)}T2cZ7mYx28{N$d-ZRON`D5ucD zC5LHg^~3qj>4^YFjlZxCW<)lr{LP*UMi8z%5g620*@ks*pDim-s_+B5@WMyR`EpO~{ovX^<+=h7#*75m;O927H zn|e-_-MgYOK3`tA;;m%|8Dvf#$$*@Pn>*O*Kv?MM&8(~rE=tn%VCUT^Ny_ampiJV1 zv9a0NBZbQDCjzEr>nv2e7oR;-829&GX$@I5jP&*MV~<0bXx#W=OVEy?SG+c-N=D{; zQmVdkV?i{M_NpuXp=ghCqWWB#;bL1)WYtI-p>e+q5TEZ&ECTJse;$V zhM8m&Dfcm??_WGjefMR{p#P-R#8oz%Y6UNuX7DIFc|XuOdIDiTUNK!kk|UmvEkkMs zp;f_@9VTs~pFc})lRz~IB7;>pkgP(5z82F$`|E9sEakkq_a>-u1E1Gg_`AEnFBaC5P7>h0S;h21cZVhfuW20-IHt=D%gmxG)8Y+Kkhhq;{#r>ebnu}~_(-GP>O zwoj!G7w<-3G7fV6W1uPC3@HAel%6Ad!Kc_aChHSssmdn-c#-?xo@Pfl-w<6{qxg{)Y9PSdAW_n&05R0_yyU3hI8M-@Alo{^D2FI%YX z5=X?&cT+?EfF68owe}1?p&0L&rXkDCz*Ev6S6zLiWFHQe>ODK)IeAUZqD<;U^;j*o zkhdNUj*($uCw|`2*N=tdmS9!UdmH&1Mm}!DUwsa?wy2RzQs6v>iN8`1=omPac(erG zv?$v1i6!fWtT3%J)txq8#yndA;%R0KZOj1$GwiECAMrhKQqsb%Lw{$zRYqbdDE0fQ zEed_Sz40#-KeCb-r6_+Tj{e@=O~@M=96V}w!b26LTMZ!pRr>GQ`~#9Zks(g*2{xOH_FU+NHhf zZNn=RNm>EZnkvldA)%qbT#vME#}B2xr$^Y|gAv$Zh^ot;jn5je?B&TiPlc89T@1S6 zcvQVJXbcpRMkFyn<*REZo{>T+^^K-O_G{6zU_&m*MqzV;;haMO`du0UP zMeM>-z)6lh1Xq!t&)JaWUF}|WPKx~Swi3W)j!z&Hww$=a5TjrGP=bed>S0(nRnCiW zrJfxc79rEW`$r7Lqc2Ss&@c<&M^q*91=v1lYHL@rxOBt_H=9)z=WHNpCZ_2KDmZmo zf`N{aG2h`{bz*ui0@MyF(RmQ);D7Gn;W7FqbRB9k_%iNeS0os{eSE^!#*E|tcI0R% zcTP_Wod|-iYX45tK4~7aFaaJjxuZv4I5)ol^y!K6iC3vZS09L(W)>81$%Z7Nz}yQR z=yIVo^dwMyI`tQC3+wpoun7rGzrN<&Afu7SD4-G0eq+sEDTvl+6l$mE87UmrNqqYr8!E2{Mno`;VILGD^+I_c&V=mb3@s27~5YY;*+7l_v z$r){r2pOfXyaf!#=7BE6I{I1i%a_WhVpOL4`%MV1q5IAo!^HQhpWi^HSMvAsErFi) z_IZR;^jqKCKtGWGfqZD3Obre)$N|be2r^PsMKKy`8^~|JHaz4FRoVh}uDNAUJS=tx zQ~eK(wHaG7qja>t&sK6f^wPfb0tCAqw|vD6n?|X`zR>D_?zEMS4J~EGP}lo_?{BuZ z%BXLGw~yA7DS~NGOIw>^BV~KOhxY2#t0c(01<;jqHZgy_*_hJwS>`H0S`An7H-L`K z_Rr|_X~P7rujs#XT?x>3b3?PXm3yhg_F@|&2zc>7K6Xo@UwxsZwhmoo4LoG%3)Jdxfg5B`rZ1?FI$bG-8p1u`NP1mre69x z{6jv70)0d!-xY%%?+1a~H2);MC_WYR24IB!y}c@ahNe*3_#E%6`>BF{Ys5@g2d9_- zuV-vawo<|;aj&X^(@JJk3dC$L7ls3WJ;ON(2oGBLOE}BStpsK2Bf98x$srK^n@&o{eF)JMF`>5@U zi|@05PUWQ|+uHW8=3k;FcgvWB`&R$IB{Z`f1cuZg0|N_f>T!QBHiT7yeHl*o2v~qX zll&p7%)YR6gl{jryY(-bi68xuRlt#ho{ES@$gp45Ak6ot)v{rloYEj!ds6bXorA-n z$q&@#Dgvv)F+jCc*0$u}mi#jRQ?baLGXy*g7n2vE1NSWKW|KsZUn%&J=7!e?{SS16 zw#eT{aZJK?Ao76|BUjJDbc^zQJ5;z8I5NGS9teo2-#?QqfFeW1&rBc!358bmD&lZh zZPG8fNV@FR2u~0h6$NUKl|88gcp~7(j~_oCg6Ui;xhh&TiIt8Hg&DY4OxL%#u&}YY zSgVHZ=e!&RzER-vG^H{%5YVq4AEp$8j%vAXpkAAUii+n>sNqSNDuB z9z90P?ia@p=-cXiJ_I9<%kkw4XiT3|975y3=aX^l z2ma9QxSLuz-)!&+=8lJ}UHnZZwg8k32~d9&FEp8!B$2C0l=ZkUO%v}6iaDrG#|u!=#x(EyTo&kqY`UmL37xQ)^bBvUU^dscWphtawC zlKGXJa!08;5%YFWt>E!&<42J*klveR#(5kDESkDZd47&dqmUM z=*TI^F{CZJi{Kui=(_*?UZ;)hIUAcEcz={GumiSZ@NYphHkP#p=#}kVYXZjqE3RN> zOLE*u_!B?u+7a_MA)yD>2P&-CHbH~qdhG!=Q{3yoE8E-K=z4n8%dq{8^Ykm{?PV3o zP`^ZJvWZ$i*uaqY2{ELJG2Aj6d~wE99B1l0%D1%r=g*--=LZ!nBTc+a4GO<|;=~DN z822zUgKO#^9D5-4$*-&oSriZL-}V-IAMb1Ss*u2KHda=L=73Z@Xif0Sk&+bEDLDdoA3UA`1=7^X?P7yfRlvED&G%rd@#s+% zTKqMjAYy0#0L>ZlH$-2%wh7unNSUpZfUGO|RO8u&Ys#;{hPlF#S>4&f=ya3I7IZH0 z-KvNN^`s(w*Az@*EesR?1G>kf;4}&Z|4^LKChE= z+>~@36dH2+_f&%%XCtcT7hc8tbP0Cx6cra|0mc86-Z@yUIS`pqFh*lRUZm`CN+(=M zMC$&+BP8ei_vw@PdTsTiM?m5EXz~!B#M3$UHKOB0N8ipc85xf2^b4^B;2 zYd!ff_`zZL3d1`Q)i78-@*X=z(Uh%jap5j0#jK|dhUYzDJj6&esb8l5z;cx#Q8Xk? z7JHsCS0C9yn$cat?V5F{#Hk}N9p2j7Vr^yoLaWF6kL5fqe`YveeGnZhEN#2L zE7-ZF!d6`=oxzXr($>XVIwf1d9s?~y+#VVn{#Flk0Wb+HOD84Aaw6j4pcADFRm*oG zuv9rc!c^%+Kz>fh!#VTv7E0W-dYr=%i?LgV=HiP2H9Hq_!N9c^vH z-si4uJfBHW{kX4Y`EVeIz0^ErWqG+&GYGAe#org(@N?#E8&{;JHAx1)zt0mur|)X; zF*PeIX;VgxXvbqof#0Bt+WeAPo8|xYED4#^QSB{KsULt;{jl z14ITG@RjkJie5^7k^dmaV`F21&%(|S_~trKJJ@zJmKP9gP!C|skw#n}{B$rwxqw?Y z0106oL|0g|q7t-wcL|q<)QHv`QSk>H3WJ12EN3MWMB|x_Hw;KbV6Q4pHa5z$G>YLf zD$alaK)O^bgvK9!Sv@{%+-@w}SS5G~cL(PCbrkL{_rBE6#oI4qC<+{cgH^@ph73Rv zVb}gTHFb&?m1=rpSV3z@VdL&qV3Xuwz$i)vLmpZXAojm2=)OyN5 zM6HO2A-l5@=Zxm|?8lGKbD9&L0{?76AspW;4_AvX<6@kho#Q@=%SzpbJ}PtM$ozl) zGwuBGwIC)z=0GuHGdOi4o!9 zog3gt6Mn5t(*zjHj z%rm+PR}$|B?y%(0iMMNr>EG`e-pg)-G}4LPl8!@M8s%4=msx64ous2x77ER*l_?-Mt=UOS}3*R0R|hh zvosK)JLjxFlK+bj9PV%wEw_yB0EHzu+3S#91Oj1q)5&st05H1T+Fg0I0tQ5!DqZMj zVmBz)a{h{yw%<0~?aTMT1~Rt_$|?1=?C^=PF~CnLLv`^zu+9D(e9VU)Un;i!$JNz# zjPlYR$Lun}v2<7Cd~Y6-lM7=BFr~i@T-STL25;=KWpM-S^v31RK$U5J_3Al{v#+RJ z+vlZ~9fBHmSmd)Fj>4W=R|LSwh8P}Pw#UqW|AoNvg17Hm{~NeMy-sYyD(`ZKlYWaT zwEo_^6ciLNFOxs_%;U3RT(^dXhK%72kk|sxFX=vQC@gdYoC?ZxuaGFTtG{2|v1ao% zYpSU9ZIF3ko9;zhTNEt&fMA7F(1v9!fTbfPTD!pEfy^wZdY8tFojfV>S9HGsD`==i zVL^6p3uCsFmP&b$LI&#`RsDRD*hX>IT~K;PhFEa$;S%|$0K*+wIpdL?OK0d3R{Tu% zu+xHj%yR>%dLsZrFf**LulF=Rbg;wK6w024&6Jv>so0`nQA`Z(v6aFRG4rvB3B|&F zZ~$456P6&P15L`c8HPtk*;vvy!hQjb`y4ER|NYyT3lFdvt`Cm|am33SNh~mKFwMgL z@h$Fx19pea8^F}C(+&+K?8hjD`2!|8ig~-L!;YpIC}z%Ty#_`~^*rUX`p6k_A&Txe zxCiZFP(w+}0_$6})*`B}?Cr!dh=%=@(bxR_=aze;jSLOZmP3Fnpx+%4wsC1#5O4-ujz~3BZSr zyyC2EI>cH#q9>VIvJ_U|fCjRibQMG#cp1PusmZVfa?kbT2VPmBhR@3|L4pmC`Gtis z*q6Y&z|P?#=B-OUKCmA~BbZ+F1A0Z1^Sk!gx`Ow}5mfP;{yoH?m&Yt7rr?Ij&FDN~ zov=C7j#GmUV zPa<`fM?}DBcU)xe_wR~KTLlmSfu2(jtr}c$H#vC>Os!zn*6$^hK?g8wemIjr=`Bg6 zB8mY6HVkFv_Iis~jZWD44N-or%K8_xk%xXtw;-HDTpGv+xO94AZMwBFEsZ|n46O3E z`$M(1(`>l`tWA60`Y%Os^7TW_7_7n}jb z=;TT40w)q@y|gzpnrAeK*E6se!oI!I#g9y$WgmNh3;>5*-`dLlbL7?z+dep{>#jn< zHKdQl14yBZXB+z9Or6KfCmT_(8|@ck1;f91)H`@X*f$ zDFo(45CIedKIUtMtf<3hr65du=%w>6E<$b7ce&BhbXcxO?z0inAClkpdomG;x|wB=vj zu?I&2Ir0R{Qd|R!DPQ6to`Cgs4J=l(jq6XrGJt+i=g1%!jlH!<%s3`Vs z3gCE2QZ4k?uM{FAQLw1pxUm;UNedrFuqHvh6AM63%of-zMJXcBLx*(DgR6z!T@SPr zqm$FpYD5zh^eE>yH~ipZLgEt=J_LuPf=No~WOO)$24Mymb!fPl#icnW^xc}88aUOv zU$t$y?9mMx6+**imj@8&lxshK@(f^&`hbYOuCJeW$2;@E{5tWmF@p+)=`}Ez(9xzh z@+zRx9uyY-0ZGYQ!SUo-&c4URtZc4K2Bgw>rp++4z5IUq>>`SFP|c?B#&5TGB1hDeK}lnh!_y_hRv zQqS9&`|nC29q`c=@Tnlc!;_Me)5$kme!voFzTGyH$F05*R3%d?HI={P3zx3N&V3ku zbWhyBckhCe6O-Txe}F!LJHTphR4Mj}va_+Fnt=+?M${_iEg^89xDX6s*srE2g)wq#k~d0hVgv~lfmE$@B2vRTh6)Pd6fYh*9HKmhUe>ImyLgUUI5d_}l2D7a z-}4@?=kZkF@3sB*`+hF(8_SvfnMkYEJxF!UU2s53b%K1yqLC(j<7S|uHd;Qr zaN*+Y1hts9H72xBz|?i(wwW`RJv+M9nZ=y+k0G678NHp(@rVPcuzyV; zz-iy3p=r3Yl0ADA`LHlj=Qrf%3yX*zrScL6x1pgSG}sUCU46J*IoYg7$FQ_WWtb5h zw|eR@SamcY>|JUM(7>`GhpEEAh3&QF>*Q>*D6(z~mHGxv9%4$|%u2IpqaMI~^aB@H zU3`YlBOo4PLV*?mCo-oc1uK6z?qM>7_3+-txS)RGkAR3T!nXit{M;BnZqVa!1%dek z7D&?qNErPs4H6R)ETeWpTRq9j!UBNU#D7mek&Nov+0j8CkV~43u>EJNa0ztq6nK#( z{ku<8vJ#6+b=E0hfaiiw=QIw7;`!cK@+E6sfKZs3npL60Kheaxub;%mN*lpV&!~`z zmC}a1E5Bzi?I5?76c>B#maq(;$xShw!j$_gQ>@uo2`;dQ2Nypc))SWfQIy=KE^(3U&G}lY`xI5_WBe?Xh?|l zk$bUmad_~)V(NZnoGyxrVtF({#i7nh{$V1zpkTe3Y~I-pJ!)a=P0^=i8Eeaa(Q|ys z(dbDNszzZl&>dtL>MBeqXin1OlqaUG3L2aQU>NsEB+C9!_z});oEPmdYm**5V&6m) ztZ*i$EiEiC<}~dtv9NV2>y^8k0Xv1NM;>qhPSe9}a(UWou7}~3Dja^ylKfhh+&RLG z3oWogsB7Ijpbu3avG=jw`(N^IRl~N=xZLJQ@*pHGPoF%QW@&n#9J$2N(b2(S-^^P% zlm5pJ{qiq2#&MV9o=I!gJsdzzLCc_%*vyzEmnQwjZ@Ev3fLQzwjc2G#dM317o?-4V&66 zr`~U$NE=h!!I)UC@%QsnoRI+xh#7^tJapH6Cn?-ho#l(oDRd77flvXj?2Z}`t!M%f z7PKgrJ$wgu7<`o4xrU-bNmQ`cc~M*&9?MJHqIW#MF<$JvqO=0=G6odYamBE~H0`48 zkHmP~G<_&A`|p4r+fi#07OA%+KRW5{J!e}*#I zTqVSrkcOwv>ka!716mdB`|0zHA^W@A7+nu4rz*_IBvQ4{?^q7N@WSF0=|INft#quO zMZ@Pd^P!h|o7P6%y$Od#e(PZy7Y)vgQ*M1xjuy)_yAk~?wYQshxp{-S2 zuRe)P&<~qL@hVXbU&c~)BL`^A z3C|d&p`GbeCX-3$dSqO+AH<%0`0I59XaxoZar5S=Iu|X`$ii83jDI4`b=4oMw5lE; z33N)uo3vKLkJFaKzt%hF|KL_8V`*b%K~{iKUEtK!r&w0F#^41M8x?!T3`&fC%EMK@ zWA6tCJpo<43Pu&cumEf1?)t!2Op-J&&tx>w`4E9k%;4{k7fZiIs5Se^-~Hy9^tipf zA5*RCZSYH~l)Z~f16+qE-?lfEqg2;izRWz%$r+Zq5T+k>#0iwuc5*kvu6d*3(=o1l zodGR{tt-#QE%T~-Ipy*}_nOZ|$Bs=CzUTp$k8R%SQi{9IW{jM$dE%c-2`rZ){uf^8 ztbL;duSsZbpc6FD8ZZpzm-RU7Jk3ek3E+BuD$jI<0@c{a=sE<^q8dTaAYDTlx5EKM zHG()Yee~3G$`Kj|cp-1Rgta_bizR-gpWiHG7H}PfyuN)N?1G_b|75tinWrOYus(VG z$9iZJOxEzCEg#B1VKbagaChzZj?h9^Z?`%87plZ_?+fB&{cl^S5}g=&$~t-*NuHaL zA%K?6YIexdWv|diW92EXgq4u);!>FZ@t`kFiy6Yzh!p@LDn(530nrzD(tT`VtO#L}wbQJv3wNFibk+56 zXZQK@)2L2+e0zdXmRFxzHy4bV*e*-}l(=P_!xHHo&~|loK`S}uO}G9QB(b%a0EM^y zF`hMgrUB4dWw$ja=8z4UblL?j*7S4|=4(?%sv%9wf7krypKukNMGdNE3wEsk>`fb= zY$J6qv*pEzK2(F7fs2f)GF?4vU-2uTEl7WgiOW=_T2}aQU~?NE9Z0EZkDTLVP3LfA z&^P}uG2)1Ul2ch#m0iwH^ho+rygpcmOl{b(LB%7Gt7ooX0Ig)N?EFa|t@+-sBYA%d zOf;u!P4$5paFgh7{&IXS+=o-81fXJ`S4 z>;zUD@35SjL!}CJIg+otYi3Lz@#Y3`0g^$e3Wam=zor~zu~ndFi=X{^%xu4Iv?{J-)>J+3*r033^qtL*r=$$)NoeypG&+ zW46;F>?q;{PU7)PbjEYRm_)x9sl^-e@hUQubMXSkaq;unH&5d4ZNW@cvM;^J2yOtxpr1)Q?*2$|O{6dBO!vYQ}QS67E~4S)Sv{)S#0&NDK=&>FIt zARhbpjAZLtA8s$AIi;nOHN!GURru05wG!D_a!ty9KBHod*<} zU~>JfoHPeV@c-Ul+G8nZadUIC*tV;+wN>xGKP~P!!oTFQ+?z^F&Zx+#qDz>O&=+}8 zEUCWY!PG#E5`0G{BO#``T2?m11QiiM{Sn6mGO$;Kl}4dq2O+?%lP(8)L~ErHMIK#)Wghs9vn`1!jB2VZXJ>8OMmpgoTdwt|VNw*T|HF}S(;`9ncM z_&HUKu+l5RJ9E{vv^4Ap#FLXFukD47!4Eg)tJ(hhox|;xj*gD;@#Kvl9Tk%QJY_a4 z014|#k0;?pk!GprAauDoF-T-*clR6lCr1k%FAx9s5XQ#F^78We`T2efICcygh5Fg9 zYe4+JH_;3}IPgAddUrb=$xHFQ!bQR5hg{u@o4P!4F*>(yCBAya5Iu#6>1zrhJCu=8 z&4M+JkB@65@+XVhlA04f#nB7h?H=vt;Z;QKhumNW$`KR0q{BqHfN^hc@5tbwX_Y(Ye?BP(Yd_x__29t+Ke}_s0eFNjUXBh9KL@h3B*eup z%Xv2#S#o#6!h<*@oyLUyFI8QVeOT%=j-2Zs7});vXZ}A6rbBEolZl|YxL)QvYx-j_ zm}A?ts;Y-;Ow%p15f*5J_T8!afPh(Z=YykNz8a~ckzA&x)0LOW$;s*IC?~IAA_|NA z&!0bwiHdsjGpnEHNXO*6Kuwdf1nzAwc4cR0PuBD!uKDapxy?2I-QUnEn~cXO{c7c( zr!Tluoyf1QuI}jQSYTT5;-fpy(-`zl?XO=83#I-iN5|FY2?-kmIJ%xuUdlDAa)(=d zpCzNb&_zU%uVcR5Vx2?}Q#E~eTI1!Fweb?JGr>Hgg0~6*{`tT8@^o&leJ_Jg3phF2 z9pDjG)g7yl5)indt?km4C~!<4P8Jj&Gr3DaN%rRq#W0%`Y7Q5F(uw1R9%7I4k_&gaO-We;h9y4mO zr6teF$@$HOdpf36v$Nwr-`FL4 z;{$y-?iK5xiu7s@TRf)v{~`EVV4THm0h%j9x4F9Xuu7-&q}eE$Nbx ze-xdhVKZAV8$FiE+ly?*_A@~U-fWZ``PhKA<%A5QJBPg7G< z6A}^v0|P@t)!_d9UR(Q9zux#z+*M}GaVT3?^#}hCnmAm4d-^`3LeDUH1pqm=*;e0EF9Rc@+gkt@VH+xbfzM2?l zT3gS5xba%lWmQR9>e27vn#X^54RdEQOUue+AO3E??h<73JjKDr#sKi2Nx~s1DQV`_ zHDQ*r8n^jYtfYj5>JiU(on@n!tS)qvd{jA-lT zFq-9W@9tH|&d$yTZwrfy54986%Dc`eqAD0?93<`gp3Vy_(I{zh8*Tj}Q>T{lzRrA=H}*B ztZeB}UK~XfWLjEUHu(2NTBM#Fc|t+)8RqVMrBV=neW%tZVfqheF4S*UhqZls`lW>hzqlA*-5MDlh zHBbtcw{-|23>Siws>!+txOBoF=ZY2H5xdv>N&jOnY>I|s2z{>4?oSmt9oiDk2O@5N zW?a4wAMcKu6ZX6Cpb>O`kD5})zIgE&G<(aMjg>hXfjEGt)8aI68W-Lk z_Xd}hNtltnsAk&?DN*7yAdh?b@|v6+)W7Fa_r;JP?LgrB#ZIeq%1bh?ulhzlo0gT9 zvY~Lwoogm^6*%DW^Ct~|Rna_5MxR~L==4AWV)x&S?HwI~^Tb;*@`{T$zbMw3boh4* zF~PRXR7EjMQW}}c2rX-3F`zW;6v%{dLqi5Y0yl2lSosJj@3GXa`@ph2*l%&ELzY#+ ze+TGtYm28iPvFWGI{@{JB3)!$Oi77HV7jw^;W&Jt|H16D2s*xu8&T2GMqXPuO1T99 z>4)o+hk$ab4_K(F(+Uc-Wy=U#V>#4mpNymA*sH#1u@Kd3nC;lhT3{ z(^VcHt=nF(T*Kd02i7!9mUQ_6=uY>O?Tg#I+Q*xzusaEN-vVM4KT9M{!QpU+f0o~W z4i5{PVRV?AoHbYuFjYwdR+pBRo~UrCC@IkbVOGZwG;jGeGzWv>CQotBx@UUS@K#Dn zYR2rrkD87XubG$_YF<=eVEaE!m6(`Fx{5c9-&;GtiOVf_U+lzR)}o<@@)nj46NYo8 z`|kGb?X{7Sk)hZNNb@>BC9V5TPAtsKT~7+2dP|)rZmlAZeihU^IXj1(nsLj^UuO6S z5KVeVuPI0WW8YO>1Og#`+}PMiKul><|3e>r_ILWsfQC%z8&EX$&VES7`}frk*M6?A zpMvtRy1tCR@3QxMyj0)Rg4X&dw!6E#|Gj!1J3T2mxy_Zz3oM({7OLs*toAkU-MjaE zRW=zJ*H>uymG()RwEwZUA=bX_#U<{@ff512w0FR;zCJ$w2b+N##o1)YIO?6W6|%d$ zan#9Q6j#S{Q-GN`4iFR+3B5C^$A0mRH)CUPp!jK-tj{)Qm7zoovDmPNQ#PE3mlxej zst>eiG;00pbp-Fv*bD3cJVoW@4?R5eBm7-8wBO3{r0lanK>~R5EVSp`#MngWWZlbS zPh&MTHKn4WvTafD_xE2~S|Z(jkt+rjRa;w&NnpErb)>OTi4OU_)^|UQh9my%=Yhkc zT}M|};bec~@b{SDprG$%j_U8o5ad|7J)eY)v5Y2UryahzHz z!oe&`7-VrtiGH@y6Oa{vL*L#=zu#$6!~oLGA3VIl^D;59+`5fYOiav7q^^sd9JWsN z9a(x2*eQb+G;#L(FcCQTgts1uK-$$221%-Wt zpXfN8n^lazO%>$ZtT5je{mhxR=fdj&B_tAaoqSrOzv|Q6+}xK(8~LP&bh+%}pPR!* z_!t#&5eg=89o1;rF5NqPjo5B>UaJ}8tM0F>d#Jnqc2}GK%o{w-=K7DZEur5 zn67&QIANCT57M9fgyot~si$Ap)2HWfrWqF-zYTBMBcIq$LfE8Cou-*%?}}U`t3_9lsL$SS4M7ax-xtqS_diQFSV#ia*4DhdyiCiT5V4Ahps`dV>gwc~ zD6wJ5q*P54aCjc{{wl@#ShZ=0xBmuI&wD0 zgx|KcD)iZEhoRR5znKK&3X*N7_?Y-PmeCm`;3hCy><#On!2d+^1agg zgEK#A+5fpr*qU2gF+?%cO(n)9b~j$l&d)1f`4sF4&=4N~F5omd zj8|NoHW41|3bi3r2=E*mRXoK};4i|(uiTdAwA-gAfBU#rE89YXIzo=ql8;llZ$B3R z_wl{Rx-Bd$OmCez6FG-Bq${2{{p-H!>o1^-nmop;9|K3P@hZ)V43NDEuo2L=$%VSj{(m{?s+ zZAtrHbQ5o@X`ZlWcCq~`%^TPkD9xs=;47ewZ|Je1Dm4yl>vjc3yMf+_Z>gdXu~hX< zis`jak0Y)Xo8`Yb(}<46l04x0?j9FJ@+?&hZ2-m!e7jqL8VQb3i%fZ8b+zL}g+z*85Pyf@ z!gxzvfyY@CBE z05u^cD=XHIAFq7O$A8<@w8FmM3NElsg4vUhwe8*AMfuNHkF{B7iBP6QFY@hG+vpU} z+upl33R16>pHP$G7K1uXkIi&e=sEQH85!TlkLi3L8L+9)fYxyzNpl@T>Ir7|kkRvd zZ_ePgt<45F_>oob2a&lRr#XyiFg9%L)?ZTaXOSMPVy9JQM5aL0ftiTgnWg;}s~?NA z7<82Z3Yf1v0c_H#GOzVf{X`v<;#~@mM^r1bGQdnJR9aMwmzpBjHCUc(qat;yj1@+v6Yn-gg4*3MX&kp zEz}8_xN&iDRf;%>25XDRlsP*&LF%EJNHgGCWO4m0@cDDHS5#4)SFX%8VX%{0hH@(& zDEW@|_NNN(SDaE*Fs}Zz+fdkGk3Rgl5w3`#V7^LDLQ)yq2KYfX-2%7F=k)JJJ)ov# zY{VO7vXA6#xd3mM_I^-*D6B82+4d*DHO)`!&1&$Ici`%qQS9VHmZEm_6 z)|}qpL}N7%MsnQ)aX*K0-hciabvqB%m8}i9R$!Qy7#vLSrgUq*?fp9a*v6Zb6qAKY z9EbszA2n5WJt>6>E#AVKb%WPy7D?%NX+^m+$9_*%4K_7l#D(@WR51W{mqe^^n9FP# z_`n($xNdUS3aels5^{5MgBr)^h=c!2dIeG#qh;F}9|#%B?kJb`TW*on(Tl?daBZ)VHm3o_XDdP7tva$%Z0h*)!a<#>F03dsMdd`H?T{P2;4?&|PIgA zYgw6@{AQJfO2I^7K|=xOcUS|C-PXQK$aEW6umDL0oSl+}6hcf9L;XiQl!2Xt!*};5 zq3I9=v@OpW=0^tx^m@yb@0e9T6gZYtQ3^`3eFS9u;ll^do6do{HJ7EUB)=r6VlenY z^rC;&W7+&Y+}r}SI^=G>m3<^QC`GZHt-Zmrn&oP~|QMhmR1tzSzxwqi-jV{4AKpez8SEQdjeBr}{2v1083 z$%2HF%wljj9U7qCpDQc3BMT;TG8~YIKz4<9ew#B&Pr!}u{&Kv3zewFdJ6V))D;oaf z=dZU$F^BBp`C!3^g@c0eJKQGp@6FbIPfKb!%O(Lxd2upFB@72eXSiU^s`A76duf0B zv-0xt@NkXA&V;`^tM7NBUu834+ebzcW!y1Ak+B4%6k-$zgi>VVTVAX&HA29+823Cg zon2B=QkqZ2;NL|sB!8d0wVbg#O+Mi=$6mRVQ`4b#L%QO>wI~(?clwxopECbn@aP6av z`$AZ3EKUD6SaAs5Sz(|HEX(%D3~O<`H{s&CKzk|Y4zNDhXzEGUIZFzfdZ$7wS z$UCTS@C)KYu#8iL98af|BcbMCs@&N$%XbaR2r~M*y1Gfu__uHCY`f?Q2nZT?>%%X@ z;hj#TDFw@TuINOrsvDrvD_o{AR5o?yf^0bD3p#M+ZoIy#dy>r@7egKSP3j^kMIje* zduwZU)G=UxJvA>c4+7F-Nw<~d^{HwI{@)kHG>p~yR=`!nRA)aRVqI_WCn6#;F*XJ< zq9wjX^+XVjjSx{2WM{{CA%hr<7M?3YH2&kq4~5eM>)W@Jcsm;yJUxThbg`K1>i+(I zl6SlY*@aL3K2$mDywrG+fngN1O_}2;{akluX6E%&nc;?p83+>RHm4a^7^RgmVsO20 zubD!>K-9L=-ripP!Q@zbyFG-Cn*7&eQ@_LYx>Mvqea-+A%kc9a2t9mqr7pIk;)qm7`t`hxm23eOXv&o2jBt2?NvwL)cwp&(wc zs;kXXfpq_TUi)R49k8#^{4mVXQ=Ocr0`RaL_?Lb#u^M8Kb5aSu`A%1r?M~MnhI6NC zeT`NZmCz0TfAGJ*m2jlr5qq?5)?Y)od2OaHv1y+}lOhMpEiUdkR%pp`vhir+$N1P7 zH6l4F34P^CZmHA#`}g%fTo(})4LZA$q9L6bj7FcYw{OBBZr!@Y^3lph%f2s7iPtMn z-tR#8@%93wO8r}-_^QH4QYxymyA@-gIt$FI^xErOt6vG2E-frH_megzV9Eqd%lXln z5sG1Wd&Ek*5cF3#jk7mv-#Xz4Hg5Gw{AJw@Etg(mU*^5+7Kpy*9HeAq0C*%&sC0u~ zMFfc&MgZOcGJ?Q&1d6nE-dl4`kJS00J=FFjB@q0O=mzbdT7xVwr}IC5e*a`#(%#;V zY$dvQ@nSnlLLy;}81rspyYakxQVk)DgWaGVA$k1G$-c?T)Hz`H;63a<7tcO z8<|Z6;{zZlxV`)C?3j4hf37(kG(j}0f?X#yEv>i+>lj%ZJI3k}ujfjiCFoxJ-zKH@ z{b@=B={q?j%9yRKt?WJsKL~F|lKd%#@W07JXGJ--vbr)>haW8t@%da7PpIMHv4E2T zg;y<{K}t%BE3MnZfKJ7}mUXSCXto)N$1}8W)7n0@RIxq8908INgU<%_51wvsb?{jY z#S8NVr(`O3JU2B#LBRt^Zl@q%N#M~Z;(E^KynX$8f{H&T_{9rKN~4+ghOO-B3_dAQ4z5FlWzS4BJKX>Vm?V;mfy@Abf3x!3)zbL zfJggoz8|)^q6??h0NYU9^VNsDFdW}KQY;40cP3?oxrCowu#Gz5X**G_qf#^Y0 zW_PwFuxNJL;&$KguEfSB2csD`!J2W(6@j1>7S~@%5xPD$UC@pM=H~!+|qr*c1YqyyOybryN z->Q7~AK1u;T~gNVPkc!`^d0ux8qe@e?O$`zLPei38t%kiSvi3GNZgqA&qSarJN!p& z-QAIz*krciWt+d$e_GIwwsduLbU5&n1IyYl&oXK8T>)Sm>+Mvjq~!O)pa zh&1hc0i+1;1!`iY*>0)^ms&L9pULg)>|m6Y>3Y=DFIVZEL#D`h<=VG~yAfBMllSmP zG%`Fb2=t{0kmcqk$(KloaVH@mdAex%F_KvlLKgpsB?3faSk9ang%j!HQ$Aue&ilGJ z!D$u2XhT<5S4&Hl;y&Y5P44Jm#-TeH;Y;pcl~F{^sSz(YIc|~6M(JsBX`{XcEHa8% zoqr`H8-`R#|2a7UWy)rN=}aH{7Le5n8SnSf z`^q0#X)fm`QnxMF*T^O4@zssy2$*H#;tAjz-NHQ}-rL&R^0I}0k|)II=;q)m70#{! z${TO)?BISH7)ABIQ_$8dh71z!({4_I0S2=OJr!_ykb3Sf*8@DUk%1z~zEfTP^DS9b zWu-xm9^sY#?(R=c1@r5O3#o{&-eZo06cQb0c2BcqP2y^%##cC~hNdPf0CEEizVcLP^4%Km zB5}@mKHrK8KnI(Pq_6&xdq8)ad>67R!#gFYC6G5EAtB**VFC%%3edCv63>^l+M%uzr$RJFOjiVSvG9Xo1sFfsD&&C~C!p>#!r-Lht7czg#PsJ!~0s8p2+pn0I z`g#R=LG!v#lR}NOVM#>9dWfyBkQeXl?i%iItNJ5)-YKwV^d#K4ys)yuNJCQu;h~)X zdZ$aM9=I03_rZ9vs%jRyin=TQ2vtM((N=Vo}J@Fl` zJ)l6u9{$R`^_euU*m>gWT>e*T)DmhU^W(<~=ZQpqWeo5Q#QgxQcG~K>o~|RHDhx19 z%2R01{q;K#W{=_3k?DQ6v9p7|>`P6oH*7@TfKj#%N?15%BPjkV$PRu(pagK(dHf--#(s`@~zxL}f(KL$yk#HA8s7}Ew-(bLld*uiIz zZDC_$V{JVMhzrATz@h$rrE=(q z+Z7Cqj=D^JDf|4{8F(MCyxT?`Y#&^4EQ`FKRIb$dK9KZIUsrHK9< z`TX2U(s6`*qM>)_lko+zg~47`Oeu~K(i69%omE&wUNmloKRxi(-z1yBv>(uMWY!(L z^`K$ftkTsIQm8|t5FD2beW9PWuk^U$mwNO|uZuTGxjU_aMt9B4?a|1{ZwR zS5#y~L{NR_Iz5*M7Z@2T@r>PHqxF7=qSJ(j3@Q?#p`qCt8$W8bItYfz(tAOwKY{55 z7IpPD8Lyw-K0c%?A+*ij+|H2A!@Ufoice#~DMNgp=HWmV`J6$hDcI;8Ld7z~B4DXI z`Rn?ot)kVJNElck!z!?#%o&!eX*HF3kv`B=e|hD`&_{jK_-`SZ;V_9c*NkXhgvbXj z>KQ&_ccuSpSC^K!He=OGj^GVF?thL%POb*(1v((I|DF{~@?x?*mZN21z;za&3xU_tZ?}Q5YQ)D3${sGE!n>acSurX^#&k#;7G_M%>xye*NVBi2@>BK;?Nm z8X^-dWo3d!g2U${8Gs5pv3gx*-y6)Eq}5bax9-FQ#1`XbD6_TeDI0`FL!HwJeGSo10cOi=e8?H&steprpr?_e$;*k_g%tg$3CY8 z6Qct?r6eMg8|?~fmmi&KW1!;~h{a|{snJ}$2V^d#UEBhJ4AgnN;jPPoI2h9-^`q23 zN^3lVZUYRjut^{|xwt0A#^UR6Gm@f~TtAP;5Zp>nTX=nZ6QbM~xBkK;sr2azjN$#mULZ+xuWEL&|4|yCxT+ahUX?4ydiE z*;|`vyA_K>i`)0Hsi@rE+}*td>_rck*mtp$jvpq!+~KYeu1ER?|JD3K7kJp&-mW;G zlA0Q&qNiWfz3zrdj|Cs`g0DBzx*&QwXcBwnI%%TDsnkeYhWFX&wRM^6zpl}P_mU73 zbDa9J3xv9n69))p^26RPW3h&asNiaxGsv?w8O0d#TD8Nyuh*9 zK;&xl5EiK)T3>$_L`Vi;^OWy}W-zTmF-SOIud-_LH;jp~%yJq@@&-&cA@Ogo`GRk! zizN_kGgrUZ-dw~MgUFY?Nz6b5e%F57*eC7Mr$1(LfmyFdUn(j0UIoJR( z=p^fW)b%QijQ_x2_&hKW?|~lN!j37cL1-MuQ(t9WY(%_(kqh-4UH+K*)YK{Oi}rXB z?}^NN~K^7G~PaW_lq%R?`Y+MiA$`B$vI2rdhjSJj_XD@PPY5?$d}g$+)oE z!fWwFy1N_GtX*S-vKc*4AY!FBqJP5<)XaY8uq2%ba`I+gZi}%pVBIqCtZgkVpt-5965nl8?xiM! z0k?y#dGM=^*-go_b=539JUru~78$XoA5CGB>N!q#W~?xe;VI^Ext& zu6jI2I%LkE&+vMwUItxM8Z1CTKWdrTz`7x2%mrV?p?^26dtX=$tW1o z=T>F6L!eGYBqa-OeNG7uR?fTixig-}0~*kEWU4gGR*5>$D7ASBvGFO)ZKsUR=`@EY zand4@83hI3{!KiqcOxU7KOgMtgUL8?*sidPB3dMsU%ogvILOG#0$^|5p^unB4Vrgx zMwtB@+5s$su>P}N7HKNpb3M$Dx+kv#B8%HORSpie-d9)qLfH>5o95B>vmp>P&{kjg z^{Wd$$VwT56qS^OIGbWRD=mydL~8|_O;D-TFqP=!^czq>TtWik)t*P`@Wb6mkg-O< zQXRAffcHO&tcj;NGs37vw788%I9|oY#igWl!Wm|$F8tfWo-+dfzaQ0Y!cT``LjRnG M>P?k=j78A@0lk}%iU0rr literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png new file mode 100644 index 0000000000000000000000000000000000000000..d77de96864612d7e5789e861ae5308f58b84910d GIT binary patch literal 13546 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png new file mode 100644 index 0000000000000000000000000000000000000000..e3a2c02e6e67b541a1364e4e9b8d3b1c00147401 GIT binary patch literal 23464 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png new file mode 100644 index 0000000000000000000000000000000000000000..126d6415b8c84ef40dcd456a89f65f00284e180b GIT binary patch literal 15834 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png new file mode 100644 index 0000000000000000000000000000000000000000..88acfb75e71ba1fae4d96b089c98d257cd56e4cf GIT binary patch literal 23538 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png new file mode 100644 index 0000000000000000000000000000000000000000..92430abf8c4fec3f058bcca30fc5368a41bbb98e GIT binary patch literal 19153 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png new file mode 100644 index 0000000000000000000000000000000000000000..419eba5324ffe40a5a61718c623c3127bd8eb34b GIT binary patch literal 21837 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png new file mode 100644 index 0000000000000000000000000000000000000000..74180c8e9a1292984d9327a1775c887161770e4a GIT binary patch literal 8272 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png new file mode 100644 index 0000000000000000000000000000000000000000..45a44ec7aa18067dcdc0374e0f0f9ca4f0ba6397 GIT binary patch literal 20159 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png new file mode 100644 index 0000000000000000000000000000000000000000..78ac3fb7dfa78198ba3353a86dd64702c0fb8416 GIT binary patch literal 8742 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png new file mode 100644 index 0000000000000000000000000000000000000000..4b7cfe2f11f2cd1ea742e6551924517723cdebbb GIT binary patch literal 25212 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png new file mode 100644 index 0000000000000000000000000000000000000000..dd031f610673894c63053226ddf11d091b099397 GIT binary patch literal 24171 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png new file mode 100644 index 0000000000000000000000000000000000000000..024030a3d92b64d97c36076873d8308968eedb01 GIT binary patch literal 53411 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

9owh3scNLo_4|GZ54|56;_PkUKk78p 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

(?1L1VAV6Hd!k3`KKE-p|i3o?_nrPmnf-)8-QQ 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 SK7N%5{~R>3Fs#;hiTQu^dgJl{ literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ef5b4f46dcb7369d00c42bc2ed096a2c27ac16 GIT binary patch literal 19449 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 L^>bP0l+XkK$ZD{6 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png new file mode 100644 index 0000000000000000000000000000000000000000..83a7b78a6ae07320b86fc1206249ef9537124285 GIT binary patch literal 158507 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 literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png new file mode 100644 index 0000000000000000000000000000000000000000..8386310e442431c54242094f9ae00b7f2b852470 GIT binary patch literal 179609 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

-(P{i2Y79*dh+wy9s)nfhh)R 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 WV@&&7?*kV6v&nL+MTMDH{Qm(tROd1P literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png new file mode 100644 index 0000000000000000000000000000000000000000..beb4d9aeb3f7b372e9505926737dd2297a33fbbf GIT binary patch literal 32867 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

+
노선을 읽는 중… @@ -87,6 +101,13 @@ export async function openRouteEditModal( violations: string[]; }> = []; let minRadiusM = 0; + /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ + let curveInfo: RoutePlanCurve[] = []; + /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ + let curveOn: boolean[] = []; + let curveRadius: Array = []; + /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ + let picked = -1; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -198,9 +219,35 @@ export async function openRouteEditModal( ? style.getPropertyValue("--color-danger") || "#dc2626" : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); - context.arc(x, y, NODE_R, 0, Math.PI * 2); + context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); + // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. + if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { + context.save(); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.beginPath(); + context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + }); + + // 곡선 시작·끝점 — 사용자가 눈으로 곡선 범위를 알아보는 자리(2026-09-07 사용자 지시). + context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + curveInfo.forEach((curve) => { + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + context.rect( + x - CURVE_HANDLE_PX, + y - CURVE_HANDLE_PX, + CURVE_HANDLE_PX * 2, + CURVE_HANDLE_PX * 2, + ); + context.fill(); + context.stroke(); + }); }); context.restore(); } @@ -245,16 +292,100 @@ export async function openRouteEditModal( function markEdited(): void { plannedLine = []; nodeInfo = []; + curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ function curveHint(): string { - if (!nodeInfo.length) return minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + const off = curveOn.filter( + (on, index) => !on && index > 0 && index < planned.length - 1, + ).length; + const forced = curveRadius.filter((value) => value !== null).length; + const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] + .filter(Boolean) + .join(" · "); + if (!nodeInfo.length) { + const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } const bad = nodeInfo.filter((node) => node.violations.length).length; - const curves = nodeInfo.filter((node) => node.radius_m !== null).length; - return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}곳` : ""}`; + const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; + return ( + `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + + `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` + ); } + // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── + const curveBar = overlay.querySelector(".b05-routeedit__curve")!; + const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; + const curveRadiusInput = curveBar.querySelector( + ".b05-routeedit__curve-radius", + )!; + const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; + const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; + const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; + const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + + /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + function syncCurveBar(): void { + const editable = picked > 0 && picked < planned.length - 1; + curveBar.hidden = !editable; + if (!editable) return; + const on = curveOn[picked] !== false; + curveLabel.textContent = `${picked + 1}번째 꺾임점`; + curveOffBtn.hidden = !on; + curveOnBtn.hidden = on; + curveRadiusInput.disabled = !on; + curveAutoBtn.disabled = !on || curveRadius[picked] === null; + const forced = curveRadius[picked]; + const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; + curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); + const inner = nodeInfo[picked]?.inner_angle_deg; + curveInfoText.textContent = on + ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + } + + curveRadiusInput.addEventListener("change", () => { + if (picked < 0) return; + const value = Number(curveRadiusInput.value); + curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + draw(); + }); + + curveOffBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = false; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; + draw(); + }); + + curveOnBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = true; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; + draw(); + }); + + curveAutoBtn.addEventListener("click", () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; + draw(); + }); + // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; @@ -265,7 +396,13 @@ export async function openRouteEditModal( const px = event.clientX - rect.left; const py = event.clientY - rect.top; dragNode = nodeAt(px, py); - if (dragNode < 0) panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else { + panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; + } canvas.setPointerCapture(event.pointerId); }); @@ -307,8 +444,13 @@ export async function openRouteEditModal( const segment = segmentAt(px, py); if (segment < 0) return; planned.splice(segment + 1, 0, toMetric(px, py)); + // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. + curveOn.splice(segment + 1, 0, true); + curveRadius.splice(segment + 1, 0, null); + picked = segment + 1; markEdited(); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`; + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; draw(); }); @@ -322,8 +464,12 @@ export async function openRouteEditModal( return; } planned.splice(index, 1); + curveOn.splice(index, 1); + curveRadius.splice(index, 1); + picked = -1; markEdited(); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`; + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; draw(); }); @@ -372,7 +518,17 @@ export async function openRouteEditModal( showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } - void runHeavy("계획노선 반영", () => replanRoute(projectId, planned)); + void runHeavy("계획노선 반영", () => + replanRoute( + projectId, + planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + ), + ); }); overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); @@ -397,6 +553,12 @@ export async function openRouteEditModal( violations: node.violations ?? [], })); minRadiusM = plan.min_radius_m ?? 0; + curveInfo = plan.curves ?? []; + // 편집값은 「서버가 준 그대로」에서 시작한다 — 곡선이 있는 자리는 켬, 반지름은 자동. + curveOn = planned.map(() => true); + curveRadius = planned.map(() => null); + picked = -1; + syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; const normalizer = createNormalizer(drainage.meta); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index c67c77b5..fc664288 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -130,3 +130,49 @@ color: #fff; text-align: center; } + +/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). + 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ +.b05-routeedit__curve { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--color-border, #e5e7eb); + background: var(--color-surface-2, #f9fafb); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__curve-label { + font-weight: 600; + white-space: nowrap; +} + +.b05-routeedit__curve-field { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.b05-routeedit__curve-radius { + width: 5.5rem; + padding: 2px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + font: inherit; + text-align: right; +} + +.b05-routeedit__curve-radius:disabled { + background: var(--color-surface-3, #f3f4f6); + color: var(--color-text-muted, #9ca3af); +} + +.b05-routeedit__curve-info { + flex: 1 1 auto; + color: var(--color-text-muted, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} -- 2.54.0 From 43d55f498c9a8d54c05b69f2b9fb798436cad391 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:19:06 +0900 Subject: [PATCH 249/441] =?UTF-8?q?fix(B05):=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=EC=A4=84=EC=9D=B4=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=91=EC=97=90=20=EA=B9=94=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실화면에서 [곡선 지우기] 클릭이 캔버스에 가로채였음 — 「 subtree intercepts pointer events」. 원인은 세로 배치임. 상자가 세로 flex 이고 캔버스 자리가 `flex: 1 1 auto` 라 공간을 다 먹는데, 새로 넣은 편집줄에 `flex: none` 이 없어 눌려 없어지고 캔버스가 그 위를 덮었음. 바닥 단추줄과 같이 `flex: none` 을 주고 `z-index: 1` 로 올림. 실화면 재확인(용화, 20번째 꺾임점) — 네 조작 모두 동작: 고름 → 「자동 · 내각 155° · 법정 하한 12m」 곡선끔 → 「곡선 없음 — 직선이 그대로 꺾입니다」 · 상태줄 「곡선 지움 1곳」 곡선켬 → 「자동」으로 복귀 R 40 → 「값 지정」 · 상태줄 「R 지정 1곳」 자동 → 값 비고 「자동」으로 복귀 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Style_RouteEdit.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index fc664288..9933d6fb 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -134,6 +134,11 @@ /* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ .b05-routeedit__curve { + /* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고 + 이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */ + flex: none; + position: relative; + z-index: 1; display: flex; align-items: center; gap: var(--spacing-8); -- 2.54.0 From 679726124bc91e4c0f63d69fc249bae88e138919 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:20:59 +0900 Subject: [PATCH 250/441] =?UTF-8?q?feat(B06):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20B05?= =?UTF-8?q?=20=EC=99=80=20=ED=86=B5=EC=9D=BC=20=E2=80=94=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=95=8C=EC=95=BD=20=EB=A0=88=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 2-5 (2026-09-07 사용자 지시 4 「구조물 표시 통일」). B06 종단에는 구조물 마크가 아예 없었고 배수관·세월교만 다른 계통으로 그려졌음. - B05 가 쓰는 **같은 부품**(`buildStructureLane`)을 B06 종단 그래프 아래에 붙임. 이름·중복 번호·툴팁·약호가 전부 B05 규칙 그대로 따라옴. - 종단 차트가 `toX`(누가거리 → px)를 내보내 레인이 그래프와 같은 자리를 씀. - 좌측 「구조물 배치」가 목록을 받을 때마다 `setStructureMarks` 로 넘김(구조물 정본 + 관 정본). 관을 가상 구조물로 바꾸는 `pipesToStructureMarks` 는 B05 안에 있던 것을 공용 자리(`B05_Profile_Api_Structures`)로 올려 **한 벌만** 둠. - 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고름(좌측 목록 클릭과 같은 규칙). 끌기는 B05 몫이라 여기서는 자리만 보임(배수유역 재분할이 걸림). - 검증 훅 `window.__b06Marks` 추가(`__corridorBuild` 등과 같은 용도). 자체검증 — 실화면 B06 종단에 레인 1줄·알약 10개(「배수관 1~8 · 세월교 1~2」)가 B05 와 같은 표기로 뜸. 훅 값 `{structures:10, types:36}`. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 27 +++++++++ B06_Section/B06_Section_UI_Page.ts | 12 ++++ .../B06_Section_UI_Page_Structures_Panel.ts | 13 ++++ B06_Section/B06_Section_UI_Section_View.ts | 60 +++++++++++++++++++ .../B06_Section_UI_Section_View_Chart.ts | 6 +- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b5ceb147..d892eea8 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -186,6 +186,33 @@ export async function migrateLegacyStations( /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ +/** 관 지점을 **알약 레인용 가상 구조물**로 만든다 — 정본은 `pipe_points.json` 이라 + * 저장하지 않는다. B05 종단과 B06 종단이 같은 표기를 쓰도록 한 벌만 둔다 + * (2026-09-07 사용자 지시 4 「구조물 표시 통일」). */ +export function pipesToStructureMarks( + pipes: ReadonlyArray<{ + chainage_m: number; + facility: string; + options?: Record | null; + }>, +): StructureInstance[] { + return pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + placement: "point", + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + options: (pipe.options ?? {}) as Record, + memo: "", + placement_source: "automatic", + status: "draft", + revision: 0, + geometry: null, + })) as StructureInstance[]; +} + + export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 286b4e32..3f2f28b7 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,6 +4,10 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import type { + StructureInstance, + StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -139,10 +143,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + let structureMarksSink: + | ((structures: StructureInstance[], types: StructureType[]) => void) + | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). reveal: () => layout.setOptionsOpen(true), + // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). + // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. + onMarks: (structures, types) => structureMarksSink?.(structures, types), // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -439,6 +449,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). + structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 90d570f0..a0152ed3 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -18,9 +18,11 @@ import { fetchStructures, fetchStructureTypes, readPendingStructures, + pipesToStructureMarks, structureAnchorM, writePendingStructures, type StructureInstance, + type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -62,6 +64,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number) => void; /** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ reveal?: () => void; + /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ + onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 * (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */ queuePipeOptions?: (chainageM: number, patch: Record) => void; @@ -226,10 +230,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); + let markTypes: StructureType[] = []; + /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ + const pushMarks = (): void => + deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); const section = createStructuresSection({ onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); + pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); }, @@ -255,6 +264,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.chainage_m = toChainageM; currentChainageM = toChainageM; section.setPipeFacilities(pipeFacilities); + pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); } else { showToast(PIPE_MOVE_GUIDE, "error"); @@ -321,6 +331,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc fetchDetailPipePoints(projectId), ]); section.setTypes(types); + markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); @@ -348,6 +359,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); + // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. + pushMarks(); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index cadbcb1b..78cd7557 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -44,6 +44,13 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { LONG_PAD } from "./B06_Section_UI_Section_Common"; +import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; @@ -114,6 +121,12 @@ export interface SectionViewController { * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; + /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 + * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ + setStructureMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; dispose: () => void; } @@ -142,6 +155,8 @@ export function createSectionView( let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; let currentNaturalSpoilSlope: number | undefined; + let markStructures: ReadonlyArray = []; + let markTypes: ReadonlyArray = []; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -493,6 +508,39 @@ export function createSectionView( }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; + // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. + // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). + if (markStructures.length && markTypes.length) { + nodes.push( + buildStructureLane({ + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + widthPx: chartWidth, + axisWidthPx: LONG_PAD.left, + stationIntervalM: cachedStationInterval, + selectedId: null, + // B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onSelect: (structureId) => { + if (!structureId) return; + const hit = markStructures.find((entry) => entry.structure_id === structureId); + if (!hit) return; + const at = structureAnchorM(hit); + // 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙. + let best: { id: string; gap: number } | null = null; + for (const section of currentDetail?.cross_sections ?? []) { + const gap = Math.abs(section.chainage_m - at); + if (!best || gap < best.gap) best = { id: section.station_id, gap }; + } + if (best) selectStation(best.id, true); + }, + // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. + onMove: () => undefined, + }), + ); + } chartWrap.replaceChildren(...nodes); // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 @@ -694,6 +742,18 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureMarks(structures, types) { + markStructures = structures; + markTypes = types; + // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 + // (`__corridorBuild` 등과 같은 용도). + (window as unknown as { __b06Marks?: unknown }).__b06Marks = { + structures: structures.length, + types: types.length, + drawn: !!currentDetail, + }; + if (currentDetail) drawPanel(); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 468efe3c..7ab41541 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -37,6 +37,8 @@ export interface LongitudinalChartResult { axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ toChainage: (px: number) => number; + /** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */ + toX: (chainageM: number) => number; maxChainageM: number; viewFromM: number; viewToM: number; @@ -78,6 +80,8 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi const maxChainageM = longitudinalMaxChainage(detail.longitudinal); const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const toX = (chainageM: number): number => + LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0); const { fromM, toM } = visibleChainageRange( toChainage, maxChainageM, @@ -112,5 +116,5 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi visibleElevationRange(detail, fromM, toM) ?? undefined, ), ); - return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; + return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; } -- 2.54.0 From 20f343902d90710935b4e5f12f30a3b9aa01c297 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:34:04 +0900 Subject: [PATCH 251/441] =?UTF-8?q?feat(B05):=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=C2=B7=EB=81=9D=EC=A0=90=EC=9D=84=20=EB=81=8C?= =?UTF-8?q?=EB=A9=B4=20=EC=A7=81=EC=84=A0=20=EA=B0=81=EB=8F=84=EC=99=80=20?= =?UTF-8?q?R=20=EC=9D=B4=20=ED=95=A8=EA=BB=98=20=EB=B0=94=EB=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07) — 「직선과 곡선 교차점 이동 시에는 직선의 각도와 반지름 값 변경이 되」. 되물어 확정: 끄는 점은 **곡선 시작·끝점**. 셈은 서버의 반대 방향임 — 서버는 교각점·R 에서 접선점을 내고, 화면은 끈 접선점에서 교각점·R 을 구함. 끈 쪽 직선만 돌리고 반대쪽은 옛 교각점을 지나는 그대로 두어, 두 직선이 만나는 자리가 새 교각점이 되고 R = |새 교각점 − 끈 자리| / tan(교각/2) 로 나옴. **두 벌이 아니라 짝임** — 왕복이 제자리인지를 tmp/tests/test_route_polyline_handle_drag.py 3건이 지킴. 화면이 낸 (교각점, R) 을 서버 셈에 넣으면 끈 자리가 **1µm 안**으로 되돌아옴. 접선점을 직선 위에서만 밀면 교각점이 안 움직이고 R 만 커지는 것도 확인. 손잡이를 **속 빈 네모(5px)** 로 키움 — 3.5px 채운 네모는 선·노드와 색이 같아 눈에도 안 띄고 집기도 어려웠음(실화면). 곡선을 지운 자리에는 손잡이도 없음. 실화면 확인(용화) — 손잡이를 끌자 「14번째 꺾임점 · R 16.6 · 값 지정」으로 바뀜. 서버가 곡선 성분 22개(예: R 44.3m, 노드 1~3을 한 곡선으로)를 내려줌. ⚠ 그 확인 도중 **서버가 또 옛 코드로 돌고 있었음**(응답에 `curves` 키 자체가 없었음). 오늘 두 번째임 — 화면 검증 전에 응답에 새 필드가 있는지부터 볼 것. tsc --noEmit 통과 · pytest 430 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 135 ++++++++++++++++++++++-- 1 file changed, 128 insertions(+), 7 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index dae496b8..46525e10 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -37,8 +37,9 @@ const NODE_HIT_PX = 9; const NODE_R = 4; /** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ const DRAG_THRESHOLD_PX = 3; -/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 네모로 그린다. */ -const CURVE_HANDLE_PX = 3.5; +/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. + * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ +const CURVE_HANDLE_PX = 5; type Vertex = [number, number]; @@ -233,9 +234,12 @@ export async function openRouteEditModal( } }); - // 곡선 시작·끝점 — 사용자가 눈으로 곡선 범위를 알아보는 자리(2026-09-07 사용자 지시). - context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). + // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. + context.lineWidth = 2; curveInfo.forEach((curve) => { + const on = curveOn[curve.node_first] !== false; + if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); @@ -245,13 +249,97 @@ export async function openRouteEditModal( CURVE_HANDLE_PX * 2, CURVE_HANDLE_PX * 2, ); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); + context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); }); }); context.restore(); } + /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ + function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { + let best: { curve: number; end: "start" | "end" } | null = null; + let bestDistance = NODE_HIT_PX + 2; + curveInfo.forEach((curve, index) => { + (["start", "end"] as const).forEach((which) => { + const point = which === "start" ? curve.start : curve.end; + const [x, y] = toScreen([point[0], point[1]]); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = { curve: index, end: which }; + } + }); + }); + return best; + } + + /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. + * + * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 + * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). + * + * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. + * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 + * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. + * + * ⚠ 이 셈은 **서버의 반대 방향**이다(서버는 교각점·R 에서 접선점을 낸다). 두 벌이 아니라 + * 짝이며, 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + */ + function dragHandleTo( + curveIndex: number, + which: "start" | "end", + to: Vertex, + ): { apex: Vertex; radius: number } | null { + const curve = curveInfo[curveIndex]; + if (!curve) return null; + const node = curve.node_first; + const before = planned[node - 1]; + const after = planned[node + 1]; + if (!before || !after) return null; + const oldApex: Vertex = [curve.apex[0], curve.apex[1]]; + // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. + const apex = + which === "start" + ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 + : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 + if (!apex) return null; + const inner = innerAngleDeg(before, apex, after); + const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + if (!(halfTan > 1e-9)) return null; + const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); + const radius = tangent / halfTan; + if (!(radius > 0) || !Number.isFinite(radius)) return null; + return { apex, radius }; + } + + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ + function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { + const dx1 = a2[0] - a1[0]; + const dy1 = a2[1] - a1[1]; + const dx2 = b2[0] - b1[0]; + const dy2 = b2[1] - b1[1]; + const denominator = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denominator) <= 1e-12) return null; + const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; + return [a1[0] + dx1 * t, a1[1] + dy1 * t]; + } + + /** 세 점이 이루는 내각(도). 일직선이면 180. */ + function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { + const ax = before[0] - at[0]; + const ay = before[1] - at[1]; + const bx = after[0] - at[0]; + const by = after[1] - at[1]; + const la = Math.hypot(ax, ay); + const lb = Math.hypot(bx, by); + if (la <= 0 || lb <= 0) return 180; + const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); + return (Math.acos(cosine) * 180) / Math.PI; + } + /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ function nodeAt(px: number, py: number): number { let best = -1; @@ -388,6 +476,8 @@ export async function openRouteEditModal( // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; + /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ + let dragHandle: { curve: number; end: "start" | "end" } | null = null; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener("pointerdown", (event) => { @@ -395,8 +485,14 @@ export async function openRouteEditModal( const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; - dragNode = nodeAt(px, py); - if (dragNode >= 0) { + // 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작). + dragHandle = handleAt(px, py); + dragNode = dragHandle ? -1 : nodeAt(px, py); + if (dragHandle) { + picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + syncCurveBar(); + draw(); + } else if (dragNode >= 0) { picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. syncCurveBar(); draw(); @@ -410,6 +506,30 @@ export async function openRouteEditModal( const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; + if (dragHandle) { + // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). + const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + if (moved) { + const node = curveInfo[dragHandle.curve].node_first; + planned[node] = moved.apex; + curveRadius[node] = Math.round(moved.radius * 100) / 100; + curveOn[node] = true; + picked = node; + // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. + const which = dragHandle.end === "start" ? "start" : "end"; + curveInfo[dragHandle.curve] = { + ...curveInfo[dragHandle.curve], + apex: [moved.apex[0], moved.apex[1]], + radius_m: moved.radius, + [which]: toMetric(px, py), + } as (typeof curveInfo)[number]; + plannedLine = []; + nodeInfo = []; + syncCurveBar(); + draw(); + } + return; + } if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. @@ -426,12 +546,13 @@ export async function openRouteEditModal( draw(); return; } - canvas.style.cursor = nodeAt(px, py) >= 0 ? "grab" : "default"; + canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); dragNode = -1; + dragHandle = null; panFrom = null; }; canvas.addEventListener("pointerup", endDrag); -- 2.54.0 From a0bd9c8775135e2ef134f3c9857cec3f71015046 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:46:09 +0900 Subject: [PATCH 252/441] =?UTF-8?q?fix(B05):=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=ED=95=98=EB=A9=B4=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=91=94=20=EB=B0=98=EC=A7=80=EB=A6=84=EC=9D=B4=20=ED=95=98?= =?UTF-8?q?=ED=95=9C=EC=9C=BC=EB=A1=9C=20=EB=88=8C=EB=A6=AC=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실화면에서 잡음 — 곡선 하나만 지워도 나머지 반지름이 **R 12~199m → 전부 12m** 로 되돌아갔음. 원인은 편집 모델과 저장 모델이 다른 것이었음. 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는데, 그 곡선의 교각점은 앞뒤 직선을 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아님**. 편집은 「꺾임점 하나 = 곡선 하나」로 표현되므로, 묶인 채로 두면 한 번만 손대도 묶음이 낱개로 흩어지고 각 자리가 하한으로 떨어졌음. 모달이 자료를 읽을 때 **묶인 구간을 그 교각점 하나로 갈아 끼우게** 함 — 안쪽 꺾임점은 그 곡선이 대신하므로 뺌. 앞뒤 직선과 반지름이 그대로라 그려지는 선은 똑같고, 이제 손대도 안 흩어짐. 서버가 고른 반지름도 함께 들고 가 [확인] 때 되돌려 보냄. 시험 tmp/tests/test_route_polyline_apex_roundtrip.py 2건 — 갈아 끼워도 곡선 수·반지름이 같고 선이 **1mm 안**에서 일치, 그리고 안 갈아 끼우면 실제로 하한으로 눌리는 것(왜 필요한지)도 못박음. 실화면 확인(용화) — 되돌린 뒤 곡선 26곳 R 최대 198.9m → 곡선 하나 지우고 [확인] → 곡선 25곳, **R 42.5·51·198.9m 그대로**. tsc --noEmit 통과 · pytest 432 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 49 ++++++++++++++++++++----- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 46525e10..47599d47 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -667,17 +667,46 @@ export async function openRouteEditModal( // 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다 // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). const nodes = plan.nodes ?? []; - planned = nodes.map((node) => [node.x, node.y] as Vertex); - nodeInfo = nodes.map((node) => ({ - radius_m: node.radius_m, - inner_angle_deg: node.inner_angle_deg, - violations: node.violations ?? [], - })); minRadiusM = plan.min_radius_m ?? 0; - curveInfo = plan.curves ?? []; - // 편집값은 「서버가 준 그대로」에서 시작한다 — 곡선이 있는 자리는 켬, 반지름은 자동. - curveOn = planned.map(() => true); - curveRadius = planned.map(() => null); + // 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07). + // + // 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 + // 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 = + // 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로 + // 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m). + // + // 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로 + // 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다. + const curves = plan.curves ?? []; + const replaced = new Map(); + const dropped = new Set(); + curves.forEach((curve) => { + replaced.set(curve.node_first, curve); + for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { + dropped.add(index); + } + }); + planned = []; + nodeInfo = []; + curveOn = []; + curveRadius = []; + curveInfo = []; + nodes.forEach((node, index) => { + if (dropped.has(index)) return; + const curve = replaced.get(index); + const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]; + const seat = planned.length; + planned.push(at); + nodeInfo.push({ + radius_m: curve ? curve.radius_m : node.radius_m, + inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, + violations: curve ? (curve.violations ?? []) : (node.violations ?? []), + }); + curveOn.push(true); + // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. + curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); + if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); + }); picked = -1; syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); -- 2.54.0 From 136965c47280e89f7223930f7c755aaf3ef04625 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 09:07:09 +0900 Subject: [PATCH 253/441] =?UTF-8?q?fix(B06):=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=EC=9D=B4=20=EC=A2=85=EB=8B=A8=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=86=92=EC=9D=B4=EB=A5=BC=20=EB=B6=80=ED=92=80?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ④(2026-09-07)로 붙인 레인이 `chartWrap` 안에 서는데, 그래프 높이를 정할 때 잰 값에서 레인 몫을 빼지 않아 되먹임이 생겼음 — 잰 값이 커지면 그래프를 키우고, 그러면 다음 회차에 더 커짐. 실측 패널이 **16,664px** 까지 부풀어 카드·알약을 누를 수 없었음. 레인 높이(`STRUCTURE_LANE_HEIGHT_PX`)를 빼서 한 번에 수렴하게 함. 자체검증 — 패널 242px · 그래프 228px · 레인 42px · 알약 10개. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Section_View.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 78cd7557..5f75c087 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -45,7 +45,10 @@ import { } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; import { LONG_PAD } from "./B06_Section_UI_Section_Common"; -import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + buildStructureLane, + STRUCTURE_LANE_HEIGHT_PX, +} from "../B05_Profile/B05_Profile_UI_Structures_Marks"; import { structureAnchorM, type StructureInstance, @@ -491,7 +494,11 @@ export function createSectionView( const measured = chartWrap.clientHeight; lastChartAvailable = measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT); - const heights = chartHeights(lastChartAvailable); + // 알약 레인도 `chartWrap` 안에 서므로 잰 높이에 **이미 들어 있다**. 그 몫을 빼지 않으면 + // 다음 회차에 잰 값이 그만큼 더 커지고, 그 값으로 그래프를 키우면 또 커지는 되먹임이 + // 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다. + const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0; + const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT)); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); const chart = buildLongitudinalChart({ -- 2.54.0 From 55895c3eaf333c8929fdacfa726575e75dffd3ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 09:11:43 +0900 Subject: [PATCH 254/441] =?UTF-8?q?build:=20watchfiles=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20=EC=9E=90=EB=8F=99=20=EB=A6=AC=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 승인(2026-09-07). 없으면 우버콘이 StatReload 로 떨어져 0.25초마다 감시 폴더를 통째로 훑고, 그러면 요청이 없어도 CPU 를 먹음. 실측 (놀고 있는 서버) — 감시 폴더를 좁히기 전 **70%** → 좁힌 뒤 **18%** → 이 패키지 설치 뒤 **0%**. 기동 로그가 `Started reloader process [...] using WatchFiles` 로 바뀜. 보조 창(8001)도 같은 값 확인 — 리로더 0% · 서버 0%. Co-Authored-By: Claude Opus 5 (1M context) --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 741d7fc7..b185b994 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,10 @@ trimesh==4.12.2 typing_extensions==4.16.0 tzdata==2026.2 uvicorn==0.24.0 +# 자동 리로드를 **이벤트 방식**으로 바꾼다(2026-09-07 사용자 승인). 없으면 우버콘이 +# StatReload 로 떨어져 0.25초마다 폴더를 통째로 훑고, 그러면 요청이 없어도 CPU 를 먹는다 +# (실측: 감시 폴더를 좁히기 전 70%, 좁힌 뒤 18%, 이 패키지 설치 뒤 0%대). +watchfiles==1.2.0 wheel==0.47.0 whitebox==2.3.6 pytest>=8 -- 2.54.0 From 692dc1dbaa9a348042f54595b4139beae0d732c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 09:36:32 +0900 Subject: [PATCH 255/441] =?UTF-8?q?refactor(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=AA=A8=EB=8B=AC=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=85=88=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20700=EC=A4=84=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `B05_Profile_UI_RouteEdit_Curve.ts` 신설(68줄) — 손잡이 끌기 역셈 (`dragHandleTo`)·직선 교점(`intersect`)·내각(`innerAngleDeg`) 순수 기하만. - 모달은 740 → 692줄. 화면 배선·동작 불변, 셈 식 그대로 옮김. - 서버 정셈과 짝임을 새 파일 머리에 명시. 거울 시험은 새 자리를 보게 갱신. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 54 +-------------- B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts | 68 +++++++++++++++++++ 2 files changed, 71 insertions(+), 51 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 47599d47..fdf9159f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,6 +29,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; +import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -276,18 +277,7 @@ export async function openRouteEditModal( return best; } - /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. - * - * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 - * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). - * - * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. - * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 - * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. - * - * ⚠ 이 셈은 **서버의 반대 방향**이다(서버는 교각점·R 에서 접선점을 낸다). 두 벌이 아니라 - * 짝이며, 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. - */ + /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( curveIndex: number, which: "start" | "end", @@ -299,45 +289,7 @@ export async function openRouteEditModal( const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; - const oldApex: Vertex = [curve.apex[0], curve.apex[1]]; - // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. - const apex = - which === "start" - ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 - : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 - if (!apex) return null; - const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); - if (!(halfTan > 1e-9)) return null; - const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); - const radius = tangent / halfTan; - if (!(radius > 0) || !Number.isFinite(radius)) return null; - return { apex, radius }; - } - - /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ - function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { - const dx1 = a2[0] - a1[0]; - const dy1 = a2[1] - a1[1]; - const dx2 = b2[0] - b1[0]; - const dy2 = b2[1] - b1[1]; - const denominator = dx1 * dy2 - dy1 * dx2; - if (Math.abs(denominator) <= 1e-12) return null; - const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; - return [a1[0] + dx1 * t, a1[1] + dy1 * t]; - } - - /** 세 점이 이루는 내각(도). 일직선이면 180. */ - function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { - const ax = before[0] - at[0]; - const ay = before[1] - at[1]; - const bx = after[0] - at[0]; - const by = after[1] - at[1]; - const la = Math.hypot(ax, ay); - const lb = Math.hypot(bx, by); - if (la <= 0 || lb <= 0) return 180; - const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); - return (Math.acos(cosine) * 180) / Math.PI; + return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); } /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts new file mode 100644 index 00000000..49b97466 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Curve.ts + * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 + * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * + * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — + * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 + * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ +export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { + const dx1 = a2[0] - a1[0]; + const dy1 = a2[1] - a1[1]; + const dx2 = b2[0] - b1[0]; + const dy2 = b2[1] - b1[1]; + const denominator = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denominator) <= 1e-12) return null; + const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; + return [a1[0] + dx1 * t, a1[1] + dy1 * t]; +} + +/** 세 점이 이루는 내각(도). 일직선이면 180. */ +export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { + const ax = before[0] - at[0]; + const ay = before[1] - at[1]; + const bx = after[0] - at[0]; + const by = after[1] - at[1]; + const la = Math.hypot(ax, ay); + const lb = Math.hypot(bx, by); + if (la <= 0 || lb <= 0) return 180; + const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); + return (Math.acos(cosine) * 180) / Math.PI; +} + +/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. + * + * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 + * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). + * + * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. + * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 + * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. + */ +export function dragHandleTo( + before: Vertex, + oldApex: Vertex, + after: Vertex, + which: "start" | "end", + to: Vertex, +): { apex: Vertex; radius: number } | null { + // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. + const apex = + which === "start" + ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 + : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 + if (!apex) return null; + const inner = innerAngleDeg(before, apex, after); + const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + if (!(halfTan > 1e-9)) return null; + const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); + const radius = tangent / halfTan; + if (!(radius > 0) || !Number.isFinite(radius)) return null; + return { apex, radius }; +} -- 2.54.0 From ec153bc2e853a0bc6bd4a877ba7af75f3eb77382 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 09:49:14 +0900 Subject: [PATCH 256/441] =?UTF-8?q?fix(B06):=20=EB=8B=A4=EB=8B=A8=20?= =?UTF-8?q?=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=A2=8C=EC=9A=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=9D=B4=20=EB=A7=90=EC=97=86=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EB=A8=B9=EB=8D=98=20=EA=B9=8C=EB=8B=AD=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「좌우 1.0m 를 넣어도 안 움직인다」의 원인은 지형이 아니라 **선반 하한**이었음 — 좌우 이동은 선반 길이가 음수가 되지 않게 `1.2 × 상하 내림`까지 따라 나가므로, 저장값이 d 2.6m 이면 하한이 3.12m 라 1.0m 요청은 아무 변화도 못 냄. 그런데도 `shiftBlockedM`(지형에 막힌 양)은 0 이라 까닭을 알 길이 없었음. - `shiftFloorM` 추가 — 요청이 하한에 눌려 통째로 무시된 경우 그 하한을 남김. - 벽 툴팁에 한 줄 — 하한 값과 「그 아래로 넣으면 안 움직임」을 적음. 지형 탓(`shiftBlockedM`)과 자리를 나눠 두 까닭이 안 섞이게 함. - 시험 `tmp/tests/test_b06_extra_shift_floor.py` — 화면 코드를 그대로 컴파일해 Node 로 돌림. 좌우 0.5m 요청이 d 2.0m 아래에서 2.4m 로 밀리고 그 값이 알려지는 것 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 ++++ B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts | 4 ++++ B06_Section/B06_Section_UI_Cross_Culvert_Types.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index a4e68876..8888dd31 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -179,6 +179,10 @@ export function appendCulvertOverlay( (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + (wall.shiftBlockedM ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` + : "") + + (wall.shiftFloorM + ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 상하 내림의 1.2배까지는` + + ` 따라 나가야 함(그보다 안쪽은 선반이 사라짐). 그 아래로 넣으면 안 움직임` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 710c6076..2d0bbe3d 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -443,6 +443,10 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). const blocked = Math.max(0, (requestedX ?? 0) - appliedX); if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100; + // 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다 + // (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄). + const floor = shelfFloor(appliedD); + if (floor > (requestedX ?? 0) + 0.005) wall.shiftFloorM = Math.round(floor * 100) / 100; walls.push(wall); // 되받기 — 지반에 맞춰 조정한 값이 **실제 적용 높이**다(조정창이 그대로 받아 적는다). appliedAdjusts.push({ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 8329cc4a..d431a628 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -74,6 +74,10 @@ export interface WallLayout { /** 사용자가 요청한 좌우 이동을 **지형이 막아 못 나간** 거리(m, 2026-09-06). * 0보다 크면 그 자리에서 더 나가면 벽이 원지반에 묻힌다는 뜻이라 툴팁으로 알린다. */ shiftBlockedM?: number; + /** 좌우 이동의 **하한**(m, 2026-09-07). 선반 길이가 음수가 되지 않게 x 는 최소 + * `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직이고, + * 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0으로 나온다 — 그 까닭을 툴팁으로 알린다. */ + shiftFloorM?: number; /** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */ material: RevetMaterial; outward: number; -- 2.54.0 From 023c0d545afc1ee4c05c1dbf8f3caa5fca4a4d91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 10:02:03 +0900 Subject: [PATCH 257/441] =?UTF-8?q?fix(B06):=20=EB=8F=85=EB=A6=BD=20?= =?UTF-8?q?=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EC=A2=8C=EC=9A=B0=20=EC=9D=B4=EB=8F=99=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?+=20=ED=95=98=ED=95=9C=20=EC=95=8C=EB=A6=BC=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A2=81=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 독립 기슭막이(D군) 툴팁은 **따로 만들어져** 있어 「지형에 막힘」조차 안 떴음. 배관 벽과 같은 두 줄(지형 막힘 · 각도 하한)을 같은 문구로 붙임. - 기준벽 경로(`computeRevetmentLayout`)에도 `shiftFloorM` 을 남김 — 하한이 `1.2 × 상하 내림 − 자동 자리`라 d 2.6m 자리에서 1.0m 요청이 삼켜졌음. - 하한 알림은 **사용자가 실제로 값을 넣었을 때만** 뜨게 좁힘. 요청이 0이면 하한은 그냥 기본 자리라 모든 벽에 뜨면 소리만 됨. - 문구를 두 경로에 맞게 고침 — 「상하 내림의 1.2배」가 아니라 「성토선 각도(1:1.2)를 지키려면 그만큼은 나가 있어야 함」. - 시험 3건(`test_b06_extra_shift_floor.py`) — 하한 적용·알림·요청 없을 때 침묵. 미확인 — 실화면에서 알림이 뜨는 것까지는 못 봄. 지금 프로젝트(용화)의 카드가 전부 링크 카드라 좌우 이동 조작 자체가 먹지 않아 조건을 세우지 못했음. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 5 +++-- .../B06_Section_UI_Cross_Culvert_Extra.ts | 6 +++++- B06_Section/B06_Section_UI_Cross_Revetment.ts | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 8888dd31..94c24efe 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -12,6 +12,7 @@ import { REVET_EMBED_DEPTH_M, + FILL_SLOPE_RATIO_MIN, REVET_LEAN_RATIO, pipeWallThicknessM, revetHeightLimit, @@ -181,8 +182,8 @@ export function appendCulvertOverlay( ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` : "") + (wall.shiftFloorM - ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 상하 내림의 1.2배까지는` + - ` 따라 나가야 함(그보다 안쪽은 선반이 사라짐). 그 아래로 넣으면 안 움직임` + ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 2d0bbe3d..3eb2a177 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -445,8 +445,12 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100; // 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다 // (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄). + // 사용자가 실제로 넣었을 때만 알린다 — 요청이 없으면(0) 하한은 그냥 기본 자리라 + // 알릴 것이 없다(모든 벽에 뜨면 소리만 됨). const floor = shelfFloor(appliedD); - if (floor > (requestedX ?? 0) + 0.005) wall.shiftFloorM = Math.round(floor * 100) / 100; + if ((requestedX ?? 0) > 0.005 && floor > (requestedX ?? 0) + 0.005) { + wall.shiftFloorM = Math.round(floor * 100) / 100; + } walls.push(wall); // 되받기 — 지반에 맞춰 조정한 값이 **실제 적용 높이**다(조정창이 그대로 받아 적는다). appliedAdjusts.push({ diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 896ca8de..5c45ff3d 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -240,6 +240,13 @@ export function computeRevetmentLayout( lengthM, floatGapM, }); + // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). + // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, + // 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다. + const floorNow = xFloor(appliedDrop); + if (requestedX > 0.005 && floorNow > requestedX + 0.005) { + tier1.shiftFloorM = Math.round(floorNow * 100) / 100; + } // 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로. const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1)); @@ -363,7 +370,16 @@ export function appendRevetmentOverlay( (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + - (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""); + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + + // 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다 + // (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다. + (wall.shiftBlockedM + ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` + : "") + + (wall.shiftFloorM + ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` + : ""); const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId); if (onSelect) shape.classList.add("is-selectable"); drawn.push(shape); -- 2.54.0 From 09442ae16548b9d54860739a6daf74fe5035ad71 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 10:04:22 +0900 Subject: [PATCH 258/441] =?UTF-8?q?fix(B06):=20=EB=8B=A8=EC=9D=B4=20?= =?UTF-8?q?=EB=AA=BB=20=EC=84=B0=EB=8B=A4=EB=8A=94=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=EC=A0=95=EC=B0=BD=20=EB=B0=96=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EC=97=90=EC=84=9C=EB=8F=84=20=EB=9D=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3-7 (2026-09-07). 안내는 이미 있었으나 `activeRevet` 이 그 벽일 때만 떠서, 좌측 「구조물 배치」 폼처럼 **조정창을 안 거친 경로**로 단 수를 바꾸면 개수만 조용히 잘렸음. 사용자는 [+]를 눌러도 아무 일이 없는 것으로 보임. - 조건을 없애고 **자리(측점·측·세워진 단 수)마다 한 번만** 내도록 바꿈. 다시 그릴 때마다 뜨면 시끄럽고, 결과가 달라지면(지형·조작) 키가 달라져 다시 알림. - 요청대로 다 선 자리는 기록을 지워, 나중에 또 모자라면 다시 알림. - 문구는 기존 `B06_Cross_Extra_Limit` 그대로 — 새 문구를 만들지 않음(중복 토스트 방지). 앞서 `Station_Controls.syncCount` 에 같은 안내를 붙였다가 **기존 것과 겹쳐 두 줄로 뜨는 것**을 실화면에서 보고 되돌렸음. 안내는 이 한 자리에만 둠. tsc 통과 · 시험 508 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Wire.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 65969bc3..27c1484b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -152,6 +152,28 @@ export interface CulvertLink { * 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다. * 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다. */ +/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다. + * 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */ +const extraLimitNotified = new Set(); + +/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다. + * 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다 + * (2026-09-07). 문구는 종전 그대로 쓴다. */ +function noticeExtraLimit(chainageM: number, side: "outlet" | "basin", built: number): void { + const key = `${chainageM.toFixed(2)}|${side}|${built}`; + if (extraLimitNotified.has(key)) return; + extraLimitNotified.add(key); + showToast(L("B06_Cross_Extra_Limit").replace("{n}", String(built)), "info"); +} + +/** 요청대로 다 선 자리는 안내 기록을 지운다 — 다음에 또 모자라면 다시 알린다. */ +function clearExtraLimitNotice(chainageM: number, side: "outlet" | "basin"): void { + const head = `${chainageM.toFixed(2)}|${side}|`; + for (const key of [...extraLimitNotified]) { + if (key.startsWith(head)) extraLimitNotified.delete(key); + } +} + export function tierSpanOf(section: CrossSection, key: string): StructureSpan { const stored = section.design?.extra_spans?.[key]; if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; @@ -470,24 +492,18 @@ export function computeCardCulvert( // 유입측(집수정 계류측·독립 기슭막이)도 유출측과 같은 규칙으로 알린다 — 종전에는 // 조용히 잘려서 +를 눌러도 아무 일이 없는 것처럼 보였다(2026-08-29 사용자 지적: // 우측 독립 기슭막이에서 추가2가 안 서는데 토스트가 없다). - if (activeRevet === "inlet" || activeRevet?.startsWith("bextra")) { - showToast( - L("B06_Cross_Extra_Limit").replace("{n}", String(layout.basinExtras.length)), - "info", - ); - } + noticeExtraLimit(section.chainage_m, "basin", layout.basinExtras.length); extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); + } else if (extraWalls) { + clearExtraLimitNotice(section.chainage_m, "basin"); } if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) { // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. - if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) { - showToast( - L("B06_Cross_Extra_Limit").replace("{n}", String(layout.extraWalls.length)), - "info", - ); - } + noticeExtraLimit(section.chainage_m, "outlet", layout.extraWalls.length); extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet"); + } else if (extraWalls) { + clearExtraLimitNotice(section.chainage_m, "outlet"); } if (revetOffset) { const roles: Array<[RevetKey, WallAdjust]> = [ -- 2.54.0 From 744059aea1ac83b2e9b302375e135acef4806e52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 10:49:29 +0900 Subject: [PATCH 259/441] =?UTF-8?q?refactor(B06):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B0=92=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=8C=EB=A1=9C=20=E2=80=94=20=EC=86=90=EB=82=98=EC=97=B4?= =?UTF-8?q?=20=EB=91=90=20=EA=B3=B3=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 사용자 값을 더할 때 **네 곳을 다 고쳐야** 했고, 하나만 빠지면 그 값이 저장에서 조용히 사라졌음(`extra_spans` 실사고 `b6941bd2`). 나열을 걷어 목록 한 벌로 모음. - 확정·저장 payload 조립(`_Router_Confirm.py`) — `if patch_item.X is not None:` 11개를 스키마 통째 덤프 + 최상위 None 걷기로 바꿈. 새 필드가 저절로 실림. 중첩 None 은 남김(기슭막이 4축 `d: null` 은 「자동」이라는 뜻). - 브라우저 payload(`_Section_Store.ts`) — `put("X", design.X)` 10줄을 목록 순회로. - `_Cross_Refresh.ts` 에 `USER_TOUCHED_KEYS` 를 내보내고 `PRESERVED_KEYS` 는 거기에 상태 둘을 더해 만들게 함. 서버 목록과 짝임을 머리에 적음. 전수 조사 결과 `design` 키는 세 갈래였음 — ① 재계산 인자로 되먹여 사는 것 8개 ② 목록으로 베껴야 사는 것 12개 ③ 순수 계산값 24개. 위험한 칸은 ② 하나뿐임. 시험 3건 추가: - `test_b06_design_key_split.py` — 사용자 값이 ①·② 어디에도 안 걸리면 깨짐(불변식). 지금 코드는 통과 = 알려지지 않은 구멍 없음. - `test_b06_recompute_keeps_user_values.py` — 포장 재계산 뒤 ② 값 보존·① 인자 전달. - `test_b06_user_touched_keys.py` — 손나열이 되살아나면 깨지는 검사 둘 추가. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Cross_Refresh.ts | 16 +++++-- B06_Section/B06_Section_Router_Confirm.py | 55 +++++------------------ B06_Section/B06_Section_Section_Store.ts | 25 +++++------ 3 files changed, 33 insertions(+), 63 deletions(-) diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 2bf89825..5305e28d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -61,10 +61,15 @@ export interface CrossRefreshInput { shouldApply?: () => boolean; } -/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */ -const PRESERVED_KEYS = [ - "status", - "pavement_suggested", +/** + * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 + * 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS` + * 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`). + * + * 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을 + * 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + */ +export const USER_TOUCHED_KEYS = [ "display_half_width_m", "inlet_structure", "basin_adjust", @@ -77,6 +82,9 @@ const PRESERVED_KEYS = [ "revet_follow_grade", ] as const; +/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 1f66658c..e4755389 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -119,51 +119,16 @@ async def _apply_section_edits( if request and request.cross_patches: patches: list[tuple[float, dict[str, Any]]] = [] for patch_item in request.cross_patches: - patch: dict[str, Any] = {} - if patch_item.rock_boundary_offset_m is not None: - patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m - if patch_item.display_half_width_m is not None: - patch["display_half_width_m"] = patch_item.display_half_width_m - if patch_item.inlet_structure is not None: - patch["inlet_structure"] = patch_item.inlet_structure - if patch_item.basin_adjust is not None: - patch["basin_adjust"] = patch_item.basin_adjust.model_dump() - # 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 남긴다(2026-08-24). - if patch_item.revet_adjust is not None: - patch["revet_adjust"] = { - role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items() - } - if patch_item.ford_adjust is not None: - patch["ford_adjust"] = patch_item.ford_adjust.model_dump() - if patch_item.box_adjust is not None: - patch["box_adjust"] = patch_item.box_adjust.model_dump() - if patch_item.extra_wall_counts is not None: - patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump() - if patch_item.extra_spans is not None: - patch["extra_spans"] = { - wall: span.model_dump() for wall, span in patch_item.extra_spans.items() - } - if patch_item.revet_link_detached is not None: - patch["revet_link_detached"] = patch_item.revet_link_detached - if patch_item.revet_follow_grade is not None: - patch["revet_follow_grade"] = patch_item.revet_follow_grade - # 카드 버튼 선택 — 브라우저가 고른 값을 그대로 정본에 얹는다(2026-09-06). - for choice_key in ( - "ground_type", - "section_mode", - "ditch_side", - "ditch_type", - "paved", - "two_stage_slope", - ): - choice = getattr(patch_item, choice_key) - if choice is not None: - patch[choice_key] = choice - # 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다. - for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"): - value = getattr(patch_item, area_key) - if value is not None: - patch[area_key] = value + # 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을 + # 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`). + # 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다. + # (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.) + dumped = patch_item.model_dump() + patch: dict[str, Any] = { + key: value + for key, value in dumped.items() + if key != "chainage_m" and value is not None + } if patch: patches.append((patch_item.chainage_m, patch)) # 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다. diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 12368851..b82cef1a 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -30,6 +30,7 @@ import { import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; +import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; const cache = new Map(); const pending = new Map>(); @@ -160,21 +161,17 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti if (!design) continue; const patch: CrossSectionPatch = { chainage_m: section.chainage_m }; let touched = false; - const put = (key: K, value: CrossSectionPatch[K]): void => { - if (value === undefined || value === null) return; - patch[key] = value; + // 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고 + // 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이 + // 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + const source = design as unknown as Record; + const target = patch as unknown as Record; + for (const key of USER_TOUCHED_KEYS) { + const value = source[key]; + if (value === undefined || value === null) continue; + target[key] = value; touched = true; - }; - put("display_half_width_m", design.display_half_width_m); - put("inlet_structure", design.inlet_structure); - put("basin_adjust", design.basin_adjust); - put("revet_adjust", design.revet_adjust); - put("ford_adjust", design.ford_adjust); - put("box_adjust", design.box_adjust); - put("extra_wall_counts", design.extra_wall_counts); - put("extra_spans", design.extra_spans); - put("revet_link_detached", design.revet_link_detached); - put("revet_follow_grade", design.revet_follow_grade); + } if (touched) patches.push(patch); } return patches; -- 2.54.0 From 8f4c486a00085aea7d2eef3c390170c3344a7676 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 10:50:36 +0900 Subject: [PATCH 260/441] =?UTF-8?q?fix(=EA=B0=9C=EB=B0=9C=ED=99=98?= =?UTF-8?q?=EA=B2=BD):=20vite=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=9D=84=20127.0.0.1=20=EB=A1=9C=20=EB=B0=95=EC=9D=8C?= =?UTF-8?q?=20=E2=80=94=20/api=20=EA=B0=80=20IPv6=20=EB=A1=9C=20=ED=97=9B?= =?UTF-8?q?=EC=A7=9A=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획서 7-1. 화면의 **모든 `/api` 요청이 「Failed to fetch」** 가 되는데 서버는 멀쩡한 증상이 하루 종일 있었음. 원인은 프록시 대상이 `http://localhost:<포트>` 인 것 — Node 18+ 는 `localhost` 를 **IPv6(::1) 먼저** 물고, 백엔드는 `SERVER_HOST=0.0.0.0` (IPv4 전용)으로 떠서 서로 못 만남. 실측 — `127.0.0.1:8001` 200 · `[::1]:8001` 연결 실패. 고친 뒤(vite 재기동 포함) 화면에서 `/api/health` **200**, 로그인 POST **200**. 오늘 겪은 「저장 Failed to fetch」 두 번과 「화면 백지」 세 번이 이것으로 설명됨 — 부팅 때 API 가 죽으면 앱이 아무것도 안 그리고 콘솔 오류도 안 남았음. 공용 파일이라 배분 창(claude-b1) 승인을 받고 고침. 5173·5174 양쪽에 같이 적용됨. Co-Authored-By: Claude Opus 5 (1M context) --- vite.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 2dbf28fe..c7e98dd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,12 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); -const apiTarget = `http://localhost:${process.env.AISLO_API_PORT ?? "8000"}`; +// ⚠ `localhost` 로 두지 말 것 — Node 18+ 는 그것을 **IPv6(::1) 먼저** 물고, 백엔드는 +// `SERVER_HOST=0.0.0.0`(IPv4 전용)로 뜬다. 그러면 프록시가 `[::1]:<포트>` 로 붙으려다 +// 실패해 화면의 **모든 `/api` 요청이 「Failed to fetch」** 가 된다 — 서버는 멀쩡한데 +// 저장이 실패하고, 부팅 때 죽으면 화면이 통째로 백지가 된다(2026-09-07 실측: +// `127.0.0.1:8001` 200 · `[::1]:8001` 연결 실패). 숫자로 박아 그 갈림을 없앤다. +const apiTarget = `http://127.0.0.1:${process.env.AISLO_API_PORT ?? "8000"}`; /** * Vite 설정 — 임도 설계 웹앱 (Vanilla TS + Three.js) -- 2.54.0 From b8f0f7ad9d09ae070b70e85192dc406a4dbc8e9f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 10:57:09 +0900 Subject: [PATCH 261/441] =?UTF-8?q?fix(B06):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=BC=A0=202=EB=8B=A8=20=EC=A0=88=ED=86=A0?= =?UTF-8?q?=EA=B0=80=20=EC=9E=AC=EA=B3=84=EC=82=B0=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 엔진 둘이 결과 딕셔너리에 **엔진이 실제 적용했는지**를 담아 저장했고, 그 값이 다음 재계산에 인자로 되먹여졌음. 그래서 토사 측점처럼 2단을 못 쓰는 자리에서 사용자가 켜 두면 저장값이 False 로 바뀌고, 그 False 가 다시 인자가 되어 **켬이 영구히 사라졌음.** - `two_stage_slope` 를 **받은 요청값 그대로** 내보내게 함(파이썬·TS 짝 두 줄). - 「실제로 적용됐나」를 읽는 곳은 코드 전체에 없음(전수 확인) — 읽는 쪽 여섯은 모두 사용자 설정으로 씀. 2단 사면이 그려지는 근거는 이 값이 아니라 설계선 기하임. - 저장 형식 안 바뀜. 옛 저장분도 그대로 읽힘. 시험 `test_b06_two_stage_slope_kept.py` 4건 — 토사에서 켬 유지 · 되먹임 왕복 · 끔도 유지 · 「2단은 설계선이 그린다」(암은 기하가 갈리고 토사는 같음) 확인. 거울 시험은 기존 `test_b06_cross_design_mirror.py` 가 그대로 잡음. 같은 모양인 `ditch_enabled` 은 손대지 않음 — 그쪽은 저장값을 「측구 없음」 판정으로 읽는 곳이 4군데(횡단 맞춤·횡단 그리기·B07 도면·3D 카브)라 처방이 다름. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 7 ++++++- common_util/common_util_cross_design.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 6dfc383a..7cf2e4f8 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -664,7 +664,12 @@ def compute_cross_design( "ditch_type": ditch_type if geometry.has_ditch else None, "cut_slope_ratio": round(geometry.cut_ratio, 4), "soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4), - "two_stage_slope": bool(geometry.two_stage), + # 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.two_stage`)가 + # 아니다(2026-09-07). 적용 결과를 저장하면, 토사 측점처럼 못 쓰는 자리에서 false 가 + # 저장되고 그 false 가 다음 재계산 인자로 되먹여져 **사용자의 「켬」이 영구히 사라졌다.** + # 「실제로 적용됐나」를 읽는 곳은 코드 전체에 하나도 없다(2026-09-07 전수 확인) — + # 읽는 쪽은 모두 사용자 설정으로 쓴다. TS 짝: `common_util_cross_design.ts`. + "two_stage_slope": bool(two_stage_slope), "fill_slope_ratio": round(geometry.fill_ratio, 4), "roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4), "carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4), diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 5e137e28..0cfe08ea 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -384,7 +384,11 @@ export function computeCrossDesign( ditch_type: geometry.hasDitch ? ditchType : null, cut_slope_ratio: round4(geometry.cutRatio), soil_cut_slope_ratio: round4(geometry.soilCutRatio), - two_stage_slope: geometry.twoStage, + // 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.twoStage`)가 + // 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사 + // 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져 + // 사용자의 「켬」이 영구히 사라졌다. + two_stage_slope: options.twoStageSlope ?? true, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), -- 2.54.0 From f8fafd2300f359c3952af8b1f04d271945eef339 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:31:07 +0900 Subject: [PATCH 262/441] =?UTF-8?q?fix(B06):=20=EC=95=94=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EC=84=A0=20=EC=98=A4=ED=94=84=EC=85=8B=EC=9D=84=20?= =?UTF-8?q?=EC=9D=BD=EB=8A=94=20=EC=AA=BD=EC=9D=B4=20=EC=98=9B=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 쓰는 쪽은 `stateKey("rockb", …)` = `aislo:draft:rockb:{p}:{r}` 에 쓰는데, 읽는 쪽 `readRockBoundarySession` 이 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로 읽고 있었음. `writeStateRaw` 는 옛 키를 안 쓰므로 **읽는 쪽이 늘 빈 값**을 받았음. 그래서 계획선을 고쳐 횡단을 다시 계산할 때(`B06_Section_Cross_Refresh`) 사용자가 옮긴 암 경계선이 안 실려 나가 **토사/암 나눔이 기본값으로 되돌아갔음** — 수량이 갈리는 자리. [저장] payload 는 메모리 Map 을 써서 저장 자체는 무사했음. - 읽는 쪽을 등록표(`readState("rockb", …)`)로 바꿈. 옛 키 만드는 함수는 지움. - 실화면 실측(용화 5601e828 · route 169) — 경계선 -0.5 → -0.8m 로 옮기니 새 키에 `{"0.00":-0.8}` 가 쌓이고 **옛 키는 아예 없었음**(고치기 전 근거). 고친 뒤 새로고침해도 값·표기 그대로. 시험 `tmp/tests/test_session_keys_registered.py` 3건 — 등록표 밖 날문자열 세션 키를 쓰면 깨지는 그물 + 이 자리 못박기. **훑어 보니 다른 날문자열 키는 0개**로, `rockb` 가 유일한 구멍이었음. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Page_Persist.ts | 26 ++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 267761d1..5c90c6fa 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,6 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; +import { readState } from "../A00_Common/b_page_state"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -33,25 +34,22 @@ import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view" import { L } from "./B06_Section_UI_Page_Common"; /** - * 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다. - * (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.) + * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. + * + * **등록표를 거쳐 읽는다**(2026-09-07 고침). 예전에는 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로 + * 읽었는데, 쓰는 쪽(`B06_Section_UI_Page.ts` 의 `stateKey("rockb", …)`)은 새 키 + * `aislo:draft:rockb:{p}:{r}` 에 쓰고 있어 **읽는 쪽이 늘 빈 값**을 받았다. 그래서 + * 계획선을 고쳐 횡단을 다시 계산할 때(`B06_Section_Cross_Refresh`) 사용자가 옮긴 암 경계선이 + * 안 실려 나가 토사/암 나눔이 기본값으로 돌아갔다 — 수량이 갈리는 자리다. + * 실측(용화 5601e828, route 169): 경계선을 -0.5 → -0.8m 로 옮기니 새 키에만 `{"0.00":-0.8}` + * 가 쌓이고 옛 키는 아예 없었다. */ -export function rockBoundarySessionKey(projectId: string, routeId: number): string { - return `b06:rockb:${projectId}:${routeId}`; -} - -/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { - try { - const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId)); - const parsed = raw ? (JSON.parse(raw) as unknown) : null; - return parsed && typeof parsed === "object" ? (parsed as Record) : {}; - } catch { - return {}; - } + const stored = readState>("rockb", projectId, routeId); + return stored && typeof stored === "object" ? stored : {}; } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ -- 2.54.0 From 647ec367d03c2125d1e92c7c2f98491b82a75fe6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:37:49 +0900 Subject: [PATCH 263/441] =?UTF-8?q?docs(A00):=20=ED=91=9C=EC=A4=80?= =?UTF-8?q?=EB=8B=A8=EB=A9=B4=20=EC=B4=88=EC=95=88=EC=9D=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=92=A4=EC=97=90=EB=8F=84=20=EC=9C=A0=EC=A7=80?= =?UTF-8?q?=EC=9E=84=EC=9D=84=20=EB=93=B1=EB=A1=9D=ED=91=9C=EC=97=90=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `std-cross` 가 [저장] 뒤에도 안 비는 것이 실수인지 의도인지 갈렸음 — **의도로 확정.** 다른 초안과 달리 이 값은 브라우저가 가진 **유일한** 사용자 표준단면임: `sections/context` 가 주는 `standard_cross_section` 은 저장분이 아니라 config 기본값이고 (`B06_Section_Router.py:132`), 저장분은 서버 안에서만 쓰임 (`stored_standard_cross_section`). 비우면 브라우저 횡단 계산이 그 순간 config 기본값으로 되돌아감. [초기화]에서만 버림. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 57a6c426..7cfab979 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,7 +109,14 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, - /** 규격 횡단 측점별 지정. */ + /** 표준 횡단면 설정 패널의 편집값. + * + * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 + * 브라우저가 가진 **유일한** 사용자 표준단면이다 — `sections/context` 가 주는 + * `standard_cross_section` 은 저장분이 아니라 **config 기본값**이고(`B06_Section_Router.py:132`), + * 저장분(`stored_standard_cross_section`)은 서버 안에서만 쓰인다. 그래서 비우면 브라우저 + * 횡단 계산이 그 순간 config 기본값으로 되돌아간다. [초기화]에서만 버린다 + * (`clearStandardCrossSession`). */ "std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` }, /** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */ "cross-display": { -- 2.54.0 From a622bcb9641ba4148050db932c7748a8a7ab6910 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:40:03 +0900 Subject: [PATCH 264/441] =?UTF-8?q?fix(=EC=84=B8=EC=85=98):=20=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EB=B3=80=EA=B2=BD=20=EB=95=8C=20=EC=98=9B=20?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=C2=B7=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EA=B0=80=20=EC=95=88=20=EC=A7=80=EC=9B=8C=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20(=EA=B3=84=ED=9A=8D=EC=84=9C=200-7=20=EA=B3=81?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=E2=91=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노선을 다시 계산하면 세션의 옛 노선치가 그대로 쌓였음. 용화(5601e828)에 145·161·169 세 노선치가 남아 있는 것을 실측함. 원인은 「안 지웠다」가 아니라 **못 지웠다** — 노선 변경 자리는 새 번호를 아직 모르니 `clearDrafts(projectId)` 로 부르는데, `stateKey` 가 노선 번호 없이는 `null` 을 내어 route 범위 키가 한 개도 안 나갔음. 부르는 쪽 주석은 「남기지 않는다」였고 실제로는 다 남았음. `clearState` 한 곳에서, 노선 범위인데 노선 번호가 없으면 `aislo:<통>:<이름>:<프로젝트>:` 로 시작하는 키를 모두 쓸어 내게 함. `clearDrafts` 와 `clearResults` 가 함께 나음. 노선 번호를 주면 종전대로 그 한 벌만 나감. 안전 확인 — 옛 노선치를 읽는 자리 없음(`readState` 호출 8곳 전부 전역·프로젝트 범위). 노선 복원도 새 route id 를 발급하므로 옛 키는 다시 안 불림. 자체검증 — `tmp/tests/test_page_state_route_sweep.py` 3건 신설. 옛 코드로 같은 시험을 돌리면 세 벌이 그대로 남아 깨지는 것까지 확인(헛도는 시험 아님). 전체 511 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 57a6c426..7c6e06ed 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -278,6 +278,27 @@ export function clearState( projectId?: string | null, routeId?: number | string | null, ): void { + const entry = entryOf(name); + // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. + // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), + // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 + // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — + // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. + if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + try { + const doomed: string[] = []; + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + if (key && key.startsWith(prefix)) doomed.push(key); + } + doomed.forEach((key) => writeRaw(key, null)); + } catch { + /* 무시 — 저장소가 막힌 브라우저 */ + } + return; + } writeStateRaw(name, null, projectId, routeId); } @@ -290,7 +311,8 @@ export function namesInBucket(bucket: StateBucket): StateName[] { /** * 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다 - * (예전에는 파일마다 따로 지웠다). 노선을 모르면 노선 범위 초안은 남는다. + * (예전에는 파일마다 따로 지웠다). 노선을 안 주면 그 프로젝트의 **모든 노선** 초안이 나간다 + * (노선 변경 자리가 그렇게 부른다 — 새 번호를 아직 모르기 때문). */ export function clearDrafts(projectId: string | null, routeId?: number | string | null): void { namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId)); -- 2.54.0 From 72e50b3eddfb786a058feb8a1645b8398da21256 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:47:27 +0900 Subject: [PATCH 265/441] =?UTF-8?q?fix(B06):=20=EC=A0=80=EC=9E=A5=EB=90=9C?= =?UTF-8?q?=20=ED=91=9C=EC=A4=80=20=ED=9A=A1=EB=8B=A8=EB=A9=B4=EC=9D=B4=20?= =?UTF-8?q?=EC=83=88=20=ED=83=AD=EC=97=90=EC=84=9C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=97=90=20=EC=95=88=20=EC=84=9C=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 표준단면 편집값은 sessionStorage 에만 살아 **탭을 새로 열면** 사라졌음. 그때 브라우저는 config 기본값으로, 서버는 저장분으로 계산해 **같은 측점이 갈렸음**. 표준단면은 모든 측점의 횡단 모양을 정하므로 면적·유토곡선·수량까지 그대로 흐름. - `sections/context` 가 저장분을 **한 칸 더** 실어 보냄(`stored_standard_cross_section`). 기존 `standard_cross_section`(config 기본값)은 그대로 둠 — 옛 화면 안 깨짐, 마이그레이션 없음. - 브라우저는 두 화면이 함께 지나는 한 자리(`fetchSectionContext`)에서 **세션이 비었을 때만** 그 값으로 세움. 그 탭에서 고친 값이 있으면 안 건드림(초안 우선). 실측(용화 5601e828 · route 169): - 고치기 전 — 저장분 암반 횡단경사 **5%** · 측구 상단폭 **0.9m** 인데 화면이 받는 값은 **3%** · **0.69m**(config 기본값)였음. 응답에 저장분 칸 자체가 없었음. - 고친 뒤 — 응답에 0.9·5 가 실리고, **빈 새 탭**에서 B06 을 열면 세션이 `rock.ditch.top_width_m=0.9` · `rock.cross_slope_pct.max=5` 로 섬(검증 탭은 닫음). 시험 `test_b06_stored_standard_reaches_browser.py` 3건 — 칸이 따로 있고 기본은 None · 라우터가 저장분을 실음 · 브라우저가 세션이 빈 경우에만 세움(두 갈래 모두). Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Fetch.ts | 26 ++++++++++++++++++++++++-- B06_Section/B06_Section_Api_Types.ts | 3 +++ B06_Section/B06_Section_Router.py | 10 ++++++++++ B06_Section/B06_Section_Schema.py | 5 +++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 59d21970..626813ba 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -63,13 +63,35 @@ async function requestJson(path: string, init: RequestInit): Promise { * (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */ export async function fetchSectionContext(projectId: string): Promise { const cached = readState("section-context", projectId); - if (cached) return cached; + if (cached) return seedStandardCross(projectId, cached); const fresh = await requestJson( `/projects/${projectId}/sections/context`, { method: "GET" }, ); writeState("section-context", fresh, projectId); - return fresh; + return seedStandardCross(projectId, fresh); +} + +/** + * 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07). + * + * 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만 + * 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해 + * 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭 + * **0.9m**, config 기본값은 **3%**·**0.69m**). + * + * 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가 + * 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다. + */ +function seedStandardCross( + projectId: string, + context: SectionContextResponse, +): SectionContextResponse { + const stored = context.stored_standard_cross_section; + if (stored && readState("std-cross", projectId) === null) { + writeState("std-cross", stored, projectId); + } + return context; } export function clearSectionContextCache(projectId: string): void { diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index f0900921..42dacd1b 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -74,6 +74,9 @@ export interface SectionContextResponse { defaults: SectionOptionDefaults; /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ standard_cross_section: StandardCrossSection; + /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. + * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ + stored_standard_cross_section?: StandardCrossSection | null; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 2dfe6718..c497a156 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -113,6 +113,15 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON run_with_connection(_road_type), ) + # 저장된 표준 횡단면 — 브라우저 계산이 서버와 같은 값을 쓰게 함께 내려보낸다 + # (2026-09-07). 노선이 없으면 저장분도 없다. + stored_standard: dict[str, Any] | None = None + if route_context and route_context.get("route_id") is not None: + longitudinal_row = await run_with_connection( + get_longitudinal_section, project_id, int(route_context["route_id"]) + ) + stored_standard = _stored_standard_cross_section(longitudinal_row) + defaults = SectionGenerationOptions() return SectionContextResponse( project_id=str(project_id), @@ -130,6 +139,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION, ), standard_cross_section=STANDARD_CROSS_SECTION, + stored_standard_cross_section=stored_standard, rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9a24481b..db92a93d 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -238,6 +238,11 @@ class SectionContextResponse(BaseModel): defaults: SectionOptionDefaults # 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본. standard_cross_section: dict[str, Any] = Field(default_factory=dict) + # 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 [확정]한 값). 없으면 None(=기본값 그대로). + # 2026-09-07 추가 — 예전에는 브라우저가 이 값을 받을 길이 없어, 세션이 빈 새 탭에서 + # **화면은 config 기본값으로, 서버는 저장분으로** 계산해 같은 측점이 갈렸다. + # 기존 `standard_cross_section`(기본값)은 그대로 두고 **한 칸만 더한다**. + stored_standard_cross_section: dict[str, Any] | None = None # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 -- 2.54.0 From e2d329d35f686596cf8078113a38d470cfacd9b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:54:23 +0900 Subject: [PATCH 266/441] =?UTF-8?q?fix(=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD):=20=EC=A7=80=EB=AC=B8=EC=9D=B4=20=EB=8B=AC=EB=9D=BC?= =?UTF-8?q?=EB=8F=84=20=EA=B0=99=EC=9D=80=20=EB=85=B8=EC=84=A0=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20=EA=B4=80=20=EC=A7=80=EC=A0=90=EC=9D=84=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=94=80=20(=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=200-7=20=EA=B3=81=EA=B0=80=EC=A7=80=20=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 노선인데 지문이 늘 어긋나 관 지점이 매번 투영 이월을 탔음. 까닭 — 같은 노선을 두 파일이 다른 자릿수로 담고 있음. `planned_route.csv` 는 소수 4자리(`208403.2001`), `route_main.geojson` 은 소수 3자리 (`208403.2`)로 csv 를 mm 반올림한 사본임. 좌표 차는 최대 0.5mm 뿐인데, 지문이 `f"{x:.2f}"` 로 0.01m 자리에서 끊는 탓에 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 바뀜 — 169개 중 16개가 그랬음. 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로, 글자 일치 대신 **허용오차**로 가름. 저장된 관을 지금 노선에 투영해 **재 보기만** 하고 (`max_projection_shift`, 값은 안 고침), 최대 어긋남이 0.05m 이하면 같은 노선으로 보고 저장분을 그대로 돌려줌. 허용오차 0.05m 근거 — 실측 어긋남이 최대 0.0053m 이라 다섯 배 이상 여유이고, 사람이 노선을 실제로 고치면 관은 m 단위로 밀리므로 「같다」로 볼 위험이 없음. 투영 이월 가지는 그대로 둠 — 대신 그 가지가 실제로 돌면 WARNING 을 찍게 함. 한동안 0 인 것을 확인한 뒤에야 지울 수 있음(먼저 지우면 관이 통째로 사라짐). 자체검증 - 새 시험 3건 `tmp/tests/test_pipe_route_tolerance.py` — mm 반올림 사본은 같은 노선으로 판정되고 누가거리가 한 값도 안 움직임 / 중간을 3m 민 노선은 안 걸림 / 허용오차 범위. - 기존 `test_pipe_point_projection.py` 2건 그대로 통과(±0.7m 잔물결 노선은 여전히 투영). - 저장된 실제 3개 프로젝트 전후 대조 — 셋 다 「다름 → 투영」이 「같음 → 저장분 그대로」로 바뀜. 화면 값 변화는 최대 0.0053m · 0.0009m · 0.0005m. - 전체 514 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_drainage_pipes.py | 54 ++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index b27ac82a..247572f4 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -160,6 +160,38 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) return points +# 같은 노선으로 볼 누가거리 어긋남의 한계(m). +# +# 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이 +# `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한 +# 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 +# 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다. +# +# 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신 +# 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측 +# **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로 +# 밀리므로 그것을 「같다」로 볼 위험은 없다. +ROUTE_MATCH_TOLERANCE_M = 0.05 + + +def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None: + """저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**). + + 좌표가 없는 관이 하나라도 있으면 잴 수 없어 None. + """ + if not vertices or not points: + return None + if any(point.x is None or point.y is None for point in points): + return None + line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + if line.length <= 0: + return None + return max( + abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m)) + for point in points + ) + + def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. @@ -214,10 +246,28 @@ def load_pipe_points_file( stored_signature = str(document.get("route_signature") or "") if stored_signature == signature: return points - if vertices and points and all(p.x is not None and p.y is not None for p in points): + # 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고 + # 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나 + # 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다. + shift = max_projection_shift(points, vertices) if vertices else None + if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M: logger.info( - "배수유역: 노선이 바뀌어 관 지점 %d건을 좌표로 이월합니다 (%s).", + "배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 " + "(최대 어긋남 %.4fm ≤ %.2fm, %s).", len(points), + shift, + ROUTE_MATCH_TOLERANCE_M, + path.name, + ) + return points + if vertices and points and all(p.x is not None and p.y is not None for p in points): + # ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야 + # 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다). + logger.warning( + "배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 " + "(최대 어긋남 %s, %s).", + len(points), + f"{shift:.3f}m" if shift is not None else "잴 수 없음", path.name, ) return project_pipe_points(points, vertices) -- 2.54.0 From df02793cafd47eaf0750162d36f4eb6a0daa8a89 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 11:57:33 +0900 Subject: [PATCH 267/441] =?UTF-8?q?feat(dev):=20=EC=A7=80=EA=B8=88=20?= =?UTF-8?q?=EB=8F=84=EB=8A=94=20=EC=84=9C=EB=B2=84=EA=B0=80=20=EC=98=9B=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=9D=B8=EC=A7=80=20=ED=97=AC=EC=8A=A4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EA=B0=80=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 리로드가 조용히 죽어 **옛 코드로 도는 서버**에 오늘 두 창이 세 번 속았음 (속도 488초 오측 · 응답에 새 필드가 없는데 화면은 정상으로 보임 · 표준단면 응답이 옛 스키마). 매번 틀린 결론을 한 번씩 냈다가 되물렀음. 규칙("재기 전에 재시작")으로는 세 번 다 안 지켜져 **한 번 보면 아는 표시**로 바꿈. - `/api/health` 가 개발 모드에서 `started_at` · `code_mtime`(기동 때 본 소스 최신 수정) · `source_mtime`(지금 소스 최신 수정) · `stale` 을 함께 냄. `stale: true` 면 재시작 전 어떤 측정도 믿으면 안 됨. - 기동 로그에 감시 폴더 수와 소스 최신 수정 시각을 한 줄 남김. - 실측 — 감시 폴더의 `.py` 를 고치니 리로드는 **안 뜨고** `stale` 이 즉시 true 로 바뀜. 원인 추적은 계속함(PLAN 7-2) — 한글 경로·정션은 **아님**(`watchfiles` 를 그 폴더에 직접 걸어 보니 변경을 정상 감지). uvicorn 이 감시 폴더 12개를 선언하고도 리로드를 안 내는 자리라 더 파야 함. Co-Authored-By: Claude Opus 5 (1M context) --- main.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 716384f9..6088b1f7 100644 --- a/main.py +++ b/main.py @@ -15,7 +15,9 @@ import logging import os import signal import subprocess +import time from contextlib import asynccontextmanager +from datetime import datetime from pathlib import Path from typing import Any @@ -271,6 +273,17 @@ async def lifespan(app: FastAPI): # 시작 logger.info(f"[{ENVIRONMENT}] 앱 시작 중...") + # 지금 도는 코드가 언제 것인지 못박아 둔다 — `/api/health` 가 `stale` 로 알린다(7-2). + global _STARTED_AT, _CODE_MTIME_AT_START + _STARTED_AT = time.time() + if DEBUG: + _CODE_MTIME_AT_START = _newest_source_mtime() + logger.info( + "[reload] 감시 폴더 %d개, 소스 최신 수정 %s", + len(_reload_dirs()), + datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), + ) + # 프론트엔드 빌드 (필수) build_frontend() @@ -440,10 +453,44 @@ async def root(): } +#: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다 +#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). +#: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가 +#: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2). +_STARTED_AT = 0.0 +_CODE_MTIME_AT_START = 0.0 + + +def _newest_source_mtime() -> float: + """감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0.""" + newest = 0.0 + for folder in _reload_dirs(): + for path in Path(folder).rglob("*.py"): + try: + newest = max(newest, path.stat().st_mtime) + except OSError: + continue + return newest + + @app.get("/api/health") async def health(): - """헬스 체크""" - return {"status": "ok", "environment": ENVIRONMENT} + """헬스 체크. + + 개발 모드에서는 **코드가 최신인지**도 함께 알린다 — `stale: true` 면 소스가 더 새로우니 + (자동 리로드가 안 붙은 것) 재시작 전에는 어떤 측정도 믿으면 안 된다. + """ + body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT} + if DEBUG: + newest = _newest_source_mtime() + body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds") + body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat( + timespec="seconds" + ) + body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds") + # 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게. + body["stale"] = newest > _CODE_MTIME_AT_START + 1.0 + return body # ───────────────────────────────────────────────────────────────────────── -- 2.54.0 From 7769db3639a247c5277824231663aadca797c727 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 13:39:42 +0900 Subject: [PATCH 268/441] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC):=20?= =?UTF-8?q?=EC=B8=A1=EA=B5=AC=EB=8A=94=20=E3=80=8C=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=97=90=EC=84=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=E3=80=8D=EB=A1=9C=20=ED=91=9C=EC=8B=9C=ED=95=98=EA=B3=A0,=20B?= =?UTF-8?q?=EA=B5=B0=20=EC=97=B0=EC=9E=A5(m)=20=EC=A7=91=EA=B3=84=EB=A5=BC?= =?UTF-8?q?=20=EB=A7=8C=EB=93=A6=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 결정 두 가지를 반영함(2026-09-07). ① 측구(옆도랑) — 목록에 두되 표시만. 레지스트리에 `design_owner` 를 새로 두고 측구에 「횡단 설계」를 적음. 구조물 배치 폼에서 그 종류를 고르면 「횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.」가 뜸. `managed_by` 와 달리 저장은 그대로 되므로 배치는 계속 가능함. 까닭 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적(`ditch_area_m2`)을 파이썬·TS 짝으로 이미 셈함. 구조물로 또 세면 같은 것을 두 번 계상함. 목록 항목에 안 붙이고 폼에 붙인 것은 「한 항목 = [측점][이름]만」이 2026-08-18 사용자 지시이기 때문임. ② B군 수량 1단계 — 구조물 정본에서 시설별 연장(m)을 냄. `common_util_structure_lengths.py` 하나. B군 수량 단위가 전부 m 이라 단면 기하가 필요 없음(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2). 수량서 양식에 안 묶이므로 B08 을 실무 xlsx 양식으로 다시 짜도 그대로 씀. 빼는 것 셋 — 관 정본 소관(`managed_by`), 횡단 소관(`design_owner`), 그리고 소단측구 (놓일 소단이 아직 없음 — 계획서 3-9 뒤에 채움). 겹친 구간은 합쳐서 셈함. 같은 시설을 겹치게 두 번 넣으면 단순 합이 그 구간을 두 번 세기 때문임. 원래 합(`raw_length_m`)도 함께 내보내 겹침이 숨지 않게 함. 자체검증 - 새 시험 7건 `tmp/tests/test_structure_lengths.py` — 종류별 합산 / 겹침 제거(80m 인데 단순 합 110m) / 측구 제외 / 소단측구 제외 / 배관 제외 / 구간 없는 항목은 스키마가 막음 / 빈 프로젝트. - 화면 확인(8001·5174, `/api/health` `stale:false`) — 구조물군 B → 종류 측구 선택 시 안내가 254×44px 로 뜨고 문구가 맞음. 리셋으로 폼 되돌림. - 전체 521 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 4 +- B05_Profile/B05_Profile_Structure_Types.json | 1 + B05_Profile/B05_Profile_Structures_Schema.py | 8 ++ B05_Profile/B05_Profile_UI_Structures_Form.ts | 20 +++- .../B05_Profile_UI_Structures_Panel.ts | 7 ++ .../B05_Profile_UI_Style_Structures.css | 11 +++ common_util/common_util_structure_lengths.py | 92 +++++++++++++++++++ 7 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 common_util/common_util_structure_lengths.py diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index d892eea8..f7f83289 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -48,6 +48,9 @@ export interface StructureType { drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; + /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. + * 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */ + design_owner: string | null; reference_only: boolean; enabled: boolean; } @@ -212,7 +215,6 @@ export function pipesToStructureMarks( })) as StructureInstance[]; } - export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 9a59af11..f6862a92 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -448,6 +448,7 @@ "enabled": false, "group": "B", "name": "측구(옆도랑)", + "design_owner": "횡단 설계", "placement": "interval", "style": { "color": "#2eaadc", diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 1afe9e16..d570578e 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -65,6 +65,14 @@ class StructureType(BaseModel): drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None + # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 + # (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고, + # ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다. + # + # 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로 + # (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면 + # **같은 것을 두 번 계상**한다(2026-09-07 조사). + design_owner: str | None = None # 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다. reference_only: bool = False enabled: bool = True diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index d7410144..f09bc1cf 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,6 +28,8 @@ export interface StructuresFormElements { body: HTMLElement; groupSelect: HTMLSelectElement; typeSelect: HTMLSelectElement; + /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ + ownerNote: HTMLElement; startFields: StationFields; anchorFields: StationFields; endFields: StationFields; @@ -96,6 +98,13 @@ export function buildStructuresForm(options: { typeRow.className = "b05-structure__grid"; typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); + // 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라 + // (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자: + // 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다. + const ownerNote = document.createElement("p"); + ownerNote.className = "b05-structure__owner-note"; + ownerNote.hidden = true; + // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-structure__grid"; @@ -129,13 +138,22 @@ export function buildStructuresForm(options: { const positionDivider = document.createElement("hr"); positionDivider.className = "b05-structure__divider"; - body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); + body.append( + typeRow, + ownerNote, + positionRow, + positionDivider, + optionRow, + facilityOptions.root, + actions, + ); return { root, body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 10c1395d..6363fb7f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, @@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; + // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 + // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). + ownerNote.textContent = type?.design_owner + ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` + : ""; + ownerNote.hidden = !type?.design_owner; anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 0097f181..0339812c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -194,6 +194,17 @@ border-top: 1px solid var(--color-border, #3a3f4a); } +/* 「제원·수량은 다른 화면이 냅니다」 안내 — 경고가 아니라 안내라 색은 흐리게, + * 왼쪽 선 하나로만 구분한다(2026-09-07). */ +.b05-structure__owner-note { + margin: 0; + padding: 4px 0 4px 8px; + border-left: 2px solid var(--color-border, #3a3f4a); + color: var(--color-text-muted, #9aa1ad); + font-size: var(--text-caption, 12px); + line-height: 1.5; +} + /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py new file mode 100644 index 00000000..092ff85e --- /dev/null +++ b/common_util/common_util_structure_lengths.py @@ -0,0 +1,92 @@ +"""구조물 정본에서 **시설별 연장(m)** 을 낸다 — B군 배수시설 수량의 입력 (계획서 3-6). + +왜 연장만인가 — B군(측구·산마루측구·도수로·맹암거 등)의 수량 단위가 **전부 m** 이다 +(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2 · 소단측구 12-9-3, 지식DB +`04_수량분석정보/배수공_수량.md`). 그래서 단면 기하 없이 구간 길이만으로 셈이 된다. +별표2의 「횡단면도 각 측점 기입 물량」 목록에도 B군은 없어 도면에 그릴 의무가 없다 +(2026-09-07 조사). + +**수량서 양식에 안 묶인다** — 여기서는 종류별 연장·개소만 내고, 어느 코드·어느 칸에 +넣을지는 B08 이 정한다. B08 은 실무 xlsx 양식으로 재작업 예정이라 그 사이에 두는 것이다. +""" + +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 +# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). +PENDING_TYPE_IDS = frozenset({"ditch_berm"}) + + +def _merge(spans: list[tuple[float, float]]) -> float: + """겹치는 구간을 합쳐 실제 덮인 길이를 낸다. + + 같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인 + 길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이 + 겹쳤다는 사실은 숨지 않는다. + """ + total = 0.0 + current_start: float | None = None + current_end = 0.0 + for start, end in sorted(spans): + if current_start is None: + current_start, current_end = start, end + continue + if start <= current_end: + current_end = max(current_end, end) + continue + total += current_end - current_start + current_start, current_end = start, end + if current_start is not None: + total += current_end - current_start + return total + + +def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: + """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). + + 빼는 것 셋 — + · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. + · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 + 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). + · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + + 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 + (`StructureInstance.validate_placement_fields`). + """ + types = structure_type_map() + spans: dict[str, list[tuple[float, float]]] = {} + counts: dict[str, int] = {} + + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.placement != "interval": + continue + if definition.managed_by or definition.design_owner: + continue + if structure.type_id in PENDING_TYPE_IDS: + continue + start, end = float(structure.start_m), float(structure.end_m) + counts[structure.type_id] = counts.get(structure.type_id, 0) + 1 + spans.setdefault(structure.type_id, []).append((min(start, end), max(start, end))) + + rows: list[dict[str, Any]] = [] + for type_id, count in counts.items(): + entries = spans[type_id] + rows.append( + { + "type_id": type_id, + "group": types[type_id].group, + "name": types[type_id].name, + "count": count, + # 겹침을 지운 실제 연장 — 수량서에 쓸 값. + "length_m": round(_merge(entries), 2), + # 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻. + "raw_length_m": round(sum(end - start for start, end in entries), 2), + } + ) + rows.sort(key=lambda row: (row["group"], row["name"])) + return rows -- 2.54.0 From 9db84d9d2098f8be212deccf1194304a422b5f95 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 13:51:58 +0900 Subject: [PATCH 269/441] =?UTF-8?q?fix(=ED=99=94=EB=A9=B4):=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=86=92=EC=9D=B4=C2=B7=EC=A0=91=ED=9E=98=EC=9D=B4?= =?UTF-8?q?=20=ED=83=AD=EC=9D=84=20=EC=83=88=EB=A1=9C=20=EC=97=B4=20?= =?UTF-8?q?=EB=95=8C=EB=A7=88=EB=8B=A4=20=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=90=98=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 화면 취향까지 sessionStorage 에 있어 **탭을 새로 열거나 브라우저를 껐다 켜면** 패널 높이· 접힘·유토곡선 열림이 매번 초기화됐음. 계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 불편은 훨씬 잦았음(사용자 승인 뒤 조사에서 드러남). - 저장소를 통(bucket)으로 가름 — **`pref` 만 localStorage**, 초안·결과는 세션 그대로. 설계 데이터가 아니므로 CLAUDE.md 5장 「캐시=세션」에 걸리지 않음. - 키만 들고 저장소를 직접 만지던 화면 공용 부품 셋(`ui_template_resizer`·`_overlay`· `_overlay_drag`)이 같은 판단(`storageOf`)을 쓰게 함 — 안 그러면 높이 값만 세션에 남아 절반만 옮겨졌음(실측: 접힘 4개는 옮겨지고 높이 3개는 안 옮겨짐). - 첫 로드 때 세션에 남은 옛 취향을 한 번 쓸어 옮김 — 이 변경으로 쓰던 크기가 초기화되지 않게. 실측(공용 브라우저) — 옮긴 뒤 취향 8개 전부 localStorage, 세션 0개, 높이 보존 (종단 438px · 표 204px). **새 탭에서도 8개 그대로**(같은 값). 검증 탭은 닫음. 시험 `tmp/tests/test_prefs_persist_across_tabs.py` 4건 — 취향만 갈리는지 · 초안·결과는 세션인지 · 공용 부품이 같은 판단을 쓰는지 · 옛 값을 한 번 옮기는지. 이 조각은 DB 없이 되는 몫임. 계정 저장(`018_user_ui_prefs`)은 다음 단계. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 54 +++++++++++++++++++++++-- ui_template/ui_template_overlay.ts | 5 ++- ui_template/ui_template_overlay_drag.ts | 6 ++- ui_template/ui_template_resizer.ts | 9 +++-- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 7cfab979..5332496e 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -202,10 +202,55 @@ export function stateKey( return `${head}:${projectId}:${routeId}`; } -/** 세션 접근은 저장소가 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */ +/** + * 어느 저장소에 둘지 — **화면 취향(`pref`)만 `localStorage`**, 나머지는 세션이다. + * + * 2026-09-07 확인 — 취향까지 세션에 있어 **탭을 새로 열거나 브라우저를 껐다 켤 때마다** + * 패널 높이·접힘이 초기화됐다(계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 불편은 + * 훨씬 잦았다). 취향은 설계 데이터가 아니므로 5장 「캐시=세션」 규칙에 걸리지 않는다 — + * **설계값·초안·계산 결과는 세션 그대로** 둔다(그 경계를 흐리지 말 것). + */ +function storageFor(key: string): Storage { + return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage; +} + +/** 위와 같은 판단을 **키만 들고 저장소를 직접 만지는 화면 공용 부품**에 내준다 + * (`ui_template_resizer`·`ui_template_overlay`·`ui_template_overlay_drag`). + * 그 부품들은 등록표에서 받은 키로 높이·열림·자리를 저장하는데, 여기를 안 거치면 + * 취향만 세션에 남아 탭을 새로 열 때마다 초기화된다. */ +export function storageOf(key: string): Storage { + return storageFor(key); +} + +/** + * 세션에 남아 있던 옛 취향을 **한 번에 브라우저 저장소로 옮긴다**(2026-09-07). + * + * 취향을 쓰는 화면 부품(높이 조절자·접이식 패널)은 키만 들고 저장소를 직접 만지므로 + * 읽는 자리마다 건져 올리는 방식으로는 **높이 값이 안 옮겨졌다**(실측: 접힘 4개는 옮겨지고 + * 높이 3개는 세션에 남음). 그래서 첫 로드 때 한 번 쓸어 옮긴다 — 쓰던 패널 크기가 + * 이 변경 때문에 초기화되지 않게 한다. 여러 번 불려도 안전하다. + */ +function liftPrefsToLocalStorage(): void { + try { + const stale = Object.keys(window.sessionStorage).filter((key) => key.startsWith("aislo:pref:")); + for (const key of stale) { + const value = window.sessionStorage.getItem(key); + if (value !== null && window.localStorage.getItem(key) === null) { + window.localStorage.setItem(key, value); + } + window.sessionStorage.removeItem(key); + } + } catch { + /* 저장소가 막힌 브라우저 — 취향은 이번 세션 동안 화면 메모리로만 산다. */ + } +} + +liftPrefsToLocalStorage(); + +/** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */ function readRaw(key: string): string | null { try { - return window.sessionStorage.getItem(key); + return storageFor(key).getItem(key); } catch { return null; } @@ -213,8 +258,9 @@ function readRaw(key: string): string | null { function writeRaw(key: string, value: string | null): void { try { - if (value === null) window.sessionStorage.removeItem(key); - else window.sessionStorage.setItem(key, value); + const store = storageFor(key); + if (value === null) store.removeItem(key); + else store.setItem(key, value); } catch { /* 무시 — 값은 화면 메모리에 남는다. */ } diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 70c79471..fe396242 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -5,6 +5,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { storageOf } from "../A00_Common/b_page_state"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -84,7 +85,7 @@ export function createWorkflowPanelHandle( } function readOpenState(key: string): boolean { - return sessionStorage.getItem(key) !== "false"; + return storageOf(key).getItem(key) !== "false"; } /** @@ -191,7 +192,7 @@ function createPanel( toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(isOpen)); } - sessionStorage.setItem(storageKey, String(isOpen)); + storageOf(storageKey).setItem(storageKey, String(isOpen)); // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts index adb456bd..3dc6cdbb 100644 --- a/ui_template/ui_template_overlay_drag.ts +++ b/ui_template/ui_template_overlay_drag.ts @@ -11,6 +11,8 @@ * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). */ +import { storageOf } from "../A00_Common/b_page_state"; + const DRAG_THRESHOLD_PX = 4; /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ const MIN_TOP_PX = 64; @@ -28,7 +30,7 @@ interface PanelPosition { } function readPosition(key: string): PanelPosition | null { - const raw = sessionStorage.getItem(key); + const raw = storageOf(key).getItem(key); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; @@ -92,7 +94,7 @@ export function makePanelDraggable( root.style.bottom = "auto"; root.style.top = `${next.top}px`; } - sessionStorage.setItem(storageKey, JSON.stringify(next)); + storageOf(storageKey).setItem(storageKey, JSON.stringify(next)); } function swallowNextClick(): void { diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts index cd788b09..717aec37 100644 --- a/ui_template/ui_template_resizer.ts +++ b/ui_template/ui_template_resizer.ts @@ -1,10 +1,11 @@ import "./ui_template_resizer.css"; +import { storageOf } from "../A00_Common/b_page_state"; /* ============================================================================= * ui_template_resizer.ts * 공통 스플리터 — 패널 경계를 끌어 크기를 조절한다. * - * 조절값은 `sessionStorage`에만 남긴다. 같은 브라우저 세션에서는 페이지를 오가도 + * 조절값은 등록표가 정한 저장소에 남긴다(취향은 브라우저, 나머지는 세션 — 2026-09-07). 같은 브라우저 세션에서는 페이지를 오가도 * 유지되고, 브라우저를 다시 열면 기본값으로 돌아간다(2026-08-01 사용자 지시). * 사용자별 영구 저장은 나중에 따로 검토한다. * ========================================================================== */ @@ -61,20 +62,20 @@ export function createPanelResizer(options: PanelResizerOptions): PanelResizer { function apply(size: number, persist: boolean): void { const next = clamp(size); target.style.setProperty(cssVar, `${Math.round(next)}px`); - if (persist && storageKey) sessionStorage.setItem(storageKey, String(Math.round(next))); + if (persist && storageKey) storageOf(storageKey).setItem(storageKey, String(Math.round(next))); onResize?.(next); } function restore(): void { if (!storageKey) return; - const saved = Number(sessionStorage.getItem(storageKey)); + const saved = Number(storageOf(storageKey).getItem(storageKey)); // 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다. if (Number.isFinite(saved) && saved > 0) apply(saved, false); } function reset(): void { target.style.removeProperty(cssVar); - if (storageKey) sessionStorage.removeItem(storageKey); + if (storageKey) storageOf(storageKey).removeItem(storageKey); onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth); } -- 2.54.0 From 159e2a52267a00dae25e9bd34aba74d671e8cef5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:41:38 +0900 Subject: [PATCH 270/441] =?UTF-8?q?refactor(B06):=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=ED=91=9C=20=ED=82=A4=EB=A5=BC=20=EC=93=B0=EB=8A=94=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EA=B0=80=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EA=B3=A0=EB=A5=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오늘 같은 계통이 두 번 났음 — 키는 등록표에서 받는데 **저장소는 제 맘대로** 고르는 자리. · `rockb` — 키를 날문자열로 만들어 읽는 쪽이 늘 빈 값(`f8fafd23`) · 화면 취향 — 키는 맞았는데 저장소를 직접 골라 **절반만** 옮겨짐(`9db84d9d`) 남은 두 자리(암 경계선 저장·표시 반폭)도 `storageOf` 를 거치게 함. 지금은 둘 다 초안이라 세션이 맞지만, 통이 바뀌면 조용히 갈릴 자리였음. 그물도 넓힘(`test_session_keys_registered.py`) — 등록표 키를 쓰는 파일이 저장소를 이름으로 직접 고르면 깨짐. 고르는 곳은 `b_page_state` 하나. 앱 전역 값(`CURRENT_PROJECT_ID_KEY`)은 등록표 밖이라 제외하고 이유를 적어 둠. **훑어 보니 남은 위반 0곳.** Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Page.ts | 14 +++++--------- B06_Section/B06_Section_UI_Page_Persist.ts | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3f2f28b7..0a3f68db 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,13 +1,10 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; -import { stateKey } from "../A00_Common/b_page_state"; +import { stateKey, storageOf } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import type { - StructureInstance, - StructureType, -} from "../B05_Profile/B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -144,8 +141,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. let structureMarksSink: - | ((structures: StructureInstance[], types: StructureType[]) => void) - | null = null; + ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). @@ -533,7 +529,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const width = crossHalfWidth(); if (!key || width === undefined) return; try { - window.sessionStorage.setItem(key, String(width)); + storageOf(key).setItem(key, String(width)); } catch { /* 무시 */ } @@ -712,7 +708,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). const sessionDisplayKey = displaySessionKey(); if (sessionDisplayKey) { - const sessionDisplay = Number(window.sessionStorage.getItem(sessionDisplayKey)); + const sessionDisplay = Number(storageOf(sessionDisplayKey).getItem(sessionDisplayKey)); if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) crossHalfWidthField.input.value = sessionDisplay.toFixed(1); } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 5c90c6fa..ba03afb1 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; -import { readState } from "../A00_Common/b_page_state"; +import { readState, storageOf } from "../A00_Common/b_page_state"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -92,7 +92,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - window.sessionStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); + storageOf(storageKey).setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -105,7 +105,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = window.sessionStorage.getItem(storageKey); + const raw = storageOf(storageKey).getItem(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { -- 2.54.0 From 8a0a8f18e30e86e3bf982fa305e241c79bd8417f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:50:31 +0900 Subject: [PATCH 271/441] =?UTF-8?q?feat(=ED=99=94=EB=A9=B4):=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=B0=EC=B9=98=EB=A5=BC=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20PC=20=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EA=BF=94=EB=8F=84=20=EB=94=B0=EB=9D=BC=EC=98=A4?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 승인(2026-09-07). 앞서 취향을 localStorage 로 옮겨 **탭·재시작** 문제는 풀었고, 이 변경은 그 위에 **다른 PC 에서도 같은 배치**를 얹음. 사용자가 노트북·데스크톱 두 대를 오감. - `db_management/018_user_ui_prefs.sql` — 사용자당 한 줄, `prefs` JSON 한 칸. 칸을 나누면 취향이 늘 때마다 마이그레이션이 또 필요해 한 칸에 담음. - `GET/PUT /api/dashboard/me/ui-prefs` — 배치 값만. **설계값은 안 담음**(문자열만 받음). - 로그인이 확인된 첫 순간에 한 번 받아 로컬 위에 얹고, 취향이 바뀌면 1.5초 모아 올림. 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돔**(계획서 원문). ⚠ 같은 함정을 또 밟을 뻔했음 — 키만 아는 자리가 저장소를 직접 고르면 **올려보내기도 안 걸림**. 그래서 `storageOf` 를 없애고 **읽기·쓰기 창구 하나**(`readByKey`/`writeByKey`)로 모음. 저장소 선택과 서버 올려보내기가 그 한 곳에만 있음. 그물도 그 이름으로 갱신. **DB 는 아직 적용하지 않았음** — 표가 없으면 API 가 실패하고 화면은 로컬 값으로 도는 것이 정상 동작임. 적용 시점은 다른 창들과 맞춘 뒤. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 86 ++++++++++++++++++++-- A06_Login/A06_Login_Api_Fetch.ts | 11 +++ B01_Dashboard/B01_Dashboard_Repository.py | 35 +++++++++ B01_Dashboard/B01_Dashboard_Router.py | 21 ++++++ B01_Dashboard/B01_Dashboard_Schema.py | 10 +++ B06_Section/B06_Section_UI_Page.ts | 6 +- B06_Section/B06_Section_UI_Page_Persist.ts | 6 +- db_management/018_user_ui_prefs.sql | 25 +++++++ ui_template/ui_template_overlay.ts | 6 +- ui_template/ui_template_overlay_drag.ts | 6 +- ui_template/ui_template_resizer.ts | 8 +- 11 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 db_management/018_user_ui_prefs.sql diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 5332496e..6be81d42 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -214,12 +214,19 @@ function storageFor(key: string): Storage { return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage; } -/** 위와 같은 판단을 **키만 들고 저장소를 직접 만지는 화면 공용 부품**에 내준다 - * (`ui_template_resizer`·`ui_template_overlay`·`ui_template_overlay_drag`). - * 그 부품들은 등록표에서 받은 키로 높이·열림·자리를 저장하는데, 여기를 안 거치면 - * 취향만 세션에 남아 탭을 새로 열 때마다 초기화된다. */ -export function storageOf(key: string): Storage { - return storageFor(key); +/** + * **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다 + * (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소). + * + * 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고 + * (2026-09-07), 계정 올려보내기도 안 걸렸다. **저장소 선택과 올려보내기를 여기 한 곳**에 둔다. + */ +export function readByKey(key: string): string | null { + return readRaw(key); +} + +export function writeByKey(key: string, value: string | null): void { + writeRaw(key, value); } /** @@ -247,6 +254,72 @@ function liftPrefsToLocalStorage(): void { liftPrefsToLocalStorage(); +/* ── 계정에 붙는 화면 취향 (2026-09-07 사용자 승인) ───────────────────────────── + * 브라우저에 남긴 값 위에 **계정 값을 얹는다** — PC 를 바꿔도 같은 배치가 되게. + * 규칙 셋: + * · 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돈다**(계획서 원문). + * · 서버에 있는 키만 덮는다 — 서버가 비었다고 로컬을 지우지 않는다. + * · 취향이 바뀌면 잠시 모아 한 번에 올린다. **설계값은 여기 오지 않는다** — + * 자동저장 금지(CLAUDE.md 5장)는 설계값 규칙이고, 화면 배치는 [저장] 단추가 없다. + * ------------------------------------------------------------------------- */ +const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; +const PREFS_PUSH_DELAY_MS = 1500; +let applyingServerPrefs = false; +let prefsPushTimer: ReturnType | null = null; + +function localPrefs(): Record { + const out: Record = {}; + try { + for (const key of Object.keys(window.localStorage)) { + if (!key.startsWith("aislo:pref:")) continue; + const value = window.localStorage.getItem(key); + if (value !== null) out[key] = value; + } + } catch { + /* 저장소가 막힌 브라우저 — 올릴 것이 없다. */ + } + return out; +} + +/** 취향이 바뀌면 잠시 모아 한 번에 올린다. 실패는 조용히 넘긴다(다음 변경 때 다시 올라간다). */ +function pushUiPrefs(): void { + if (applyingServerPrefs) return; + if (prefsPushTimer) clearTimeout(prefsPushTimer); + prefsPushTimer = setTimeout(() => { + prefsPushTimer = null; + void fetch(PREFS_ENDPOINT, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prefs: localPrefs() }), + }).catch(() => undefined); + }, PREFS_PUSH_DELAY_MS); +} + +/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ +export async function syncUiPrefs(): Promise { + let stored: Record = {}; + try { + const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); + if (!response.ok) return; + const body = (await response.json()) as { prefs?: Record }; + stored = body.prefs ?? {}; + } catch { + return; // 서버가 없으면 로컬 값으로 그대로 돈다. + } + applyingServerPrefs = true; + try { + for (const [key, value] of Object.entries(stored)) { + if (!key.startsWith("aislo:pref:") || typeof value !== "string") continue; + window.localStorage.setItem(key, value); + } + } catch { + /* 저장소가 막힌 브라우저 — 이번 세션은 화면 메모리로 돈다. */ + } finally { + applyingServerPrefs = false; + } +} + /** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */ function readRaw(key: string): string | null { try { @@ -264,6 +337,7 @@ function writeRaw(key: string, value: string | null): void { } catch { /* 무시 — 값은 화면 메모리에 남는다. */ } + if (key.startsWith("aislo:pref:")) pushUiPrefs(); } /** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index c2565d99..157cb129 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -1,4 +1,5 @@ import { API_BASE_URL } from "@config/config_frontend"; +import { syncUiPrefs } from "../A00_Common/b_page_state"; export interface ApiResult { status: string; @@ -42,10 +43,14 @@ export function verifyLogin(email: string, otpCode: string): Promise 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ const SESSION_CACHE_MS = 5000; let sessionCache: { at: number; value: Promise } | null = null; +/** 계정 취향 받아오기는 한 번만 — 세션 조회는 화면마다 여러 번 불린다. */ +let uiPrefsSynced = false; /** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ export function clearSessionCache(): void { sessionCache = null; + // 로그인·로그아웃이면 다음 세션에서 취향을 다시 받아 온다(다른 계정일 수 있다). + uiPrefsSynced = false; } function sessionOnce(): Promise { @@ -56,6 +61,12 @@ function sessionOnce(): Promise { const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); if (!response.ok) return null; const data = (await response.json()) as { status: string; user?: SessionUser }; + // 로그인이 확인된 첫 순간에 계정 취향을 한 번 받아 온다 — PC 를 바꿔도 같은 배치가 + // 되게(2026-09-07). 실패해도 화면은 브라우저에 남은 값으로 그대로 돈다. + if (data.user && !uiPrefsSynced) { + uiPrefsSynced = true; + void syncUiPrefs(); + } return data.user ?? null; } catch { return null; diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 9bed97a0..b7fe4ac8 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -520,3 +520,38 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: (bucket_seconds, bucket_seconds, since, bucket_seconds), ) return {"current": current, "history": list(await cursor.fetchall()), "stats": current} + + +async def get_user_ui_prefs(user_id: int) -> dict[str, Any]: + """계정에 붙은 화면 취향(배치·표시). 없으면 빈 묶음. + + 설계값이 아니라 **패널 높이·접힘 같은 배치 값**만 담는다(표 `user_ui_prefs` 주석 참조). + 읽기 실패는 비치명 — 화면은 브라우저에 남은 값으로 그대로 돈다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user_id,)) + row = await cursor.fetchone() + if not row or not row[0]: + return {} + raw = row[0] + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return {} + return raw if isinstance(raw, dict) else {} + + +async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None: + """화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s) + ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""", + (user_id, json.dumps(prefs, ensure_ascii=False)), + ) + await connection.commit() diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 1f4740cf..0635b8dc 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -34,11 +34,13 @@ from .B01_Dashboard_Repository import ( get_project, get_system_resources, get_user_admin_target, + get_user_ui_prefs, list_all_projects, list_all_users, list_audit_logs, list_company_projects, list_user_projects, + save_user_ui_prefs, soft_delete_project, soft_delete_user, update_admin_user, @@ -85,6 +87,7 @@ from .B01_Dashboard_Schema import ( InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, + UiPrefsRequest, UpdateCompanyAssetRequest, UpdateCompanyLogoRequest, UpdateProjectRequest, @@ -169,6 +172,24 @@ async def patch_dashboard_me( return {"status": "success", "user": user} +# ── 화면 취향 (2026-09-07 사용자 승인) ─────────────────────────────────────── +# 패널 높이·접힘 같은 **배치 값**만 계정에 붙인다. 설계값은 여기 오지 않는다 — 초안·결과는 +# 브라우저에 두고 [저장]·[확정]에서만 정본으로 간다(CLAUDE.md 5장). +# 브라우저는 이 값을 **로컬 사본 위에 병합**하고, 서버가 없거나 못 읽으면 로컬 값으로 돈다. +@router.get("/me/ui-prefs") +async def read_ui_prefs(session: dict[str, Any] = Depends(verify_session)): + return {"status": "success", "prefs": await get_user_ui_prefs(int(session["user_id"]))} + + +@router.put("/me/ui-prefs") +async def write_ui_prefs( + payload: UiPrefsRequest, + session: dict[str, Any] = Depends(verify_session), +): + await save_user_ui_prefs(int(session["user_id"]), payload.prefs) + return {"status": "success"} + + @router.get("/user/projects") async def user_projects(session: dict[str, Any] = Depends(verify_session)): # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index ceab68a7..9218ab0d 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -12,6 +12,16 @@ class UpdateUserRequest(BaseModel): phone: str | None = Field(default=None, max_length=50) +class UiPrefsRequest(BaseModel): + """화면 취향 묶음 — 배치·표시 값만. 키는 브라우저 등록표(`b_page_state.ts`)가 정본이다. + + 값은 문자열 하나(높이는 `"438"`, 접힘은 `"true"`)라 저장소에 넣던 모양 그대로다. + 설계값이 섞여 들어오지 않게 **문자열만** 받는다. + """ + + prefs: dict[str, str] = Field(default_factory=dict) + + class CreateCompanyRequest(BaseModel): name: str = Field(min_length=1, max_length=255) business_registration_number: str = Field(min_length=1, max_length=20) diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 0a3f68db..30ba8b6d 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,7 +1,7 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; -import { stateKey, storageOf } from "../A00_Common/b_page_state"; +import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -529,7 +529,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const width = crossHalfWidth(); if (!key || width === undefined) return; try { - storageOf(key).setItem(key, String(width)); + writeByKey(key, String(width)); } catch { /* 무시 */ } @@ -708,7 +708,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). const sessionDisplayKey = displaySessionKey(); if (sessionDisplayKey) { - const sessionDisplay = Number(storageOf(sessionDisplayKey).getItem(sessionDisplayKey)); + const sessionDisplay = Number(readByKey(sessionDisplayKey)); if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) crossHalfWidthField.input.value = sessionDisplay.toFixed(1); } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index ba03afb1..05947b77 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; -import { readState, storageOf } from "../A00_Common/b_page_state"; +import { readByKey, readState, writeByKey } from "../A00_Common/b_page_state"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -92,7 +92,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - storageOf(storageKey).setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); + writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -105,7 +105,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = storageOf(storageKey).getItem(storageKey); + const raw = readByKey(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { diff --git a/db_management/018_user_ui_prefs.sql b/db_management/018_user_ui_prefs.sql new file mode 100644 index 00000000..d5eb0530 --- /dev/null +++ b/db_management/018_user_ui_prefs.sql @@ -0,0 +1,25 @@ +-- 018_user_ui_prefs.sql +-- 화면 취향을 계정에 붙인다 (2026-09-07 사용자 승인) +-- +-- 패널 높이·접힘, 유토곡선 열림·범례 같은 **배치 값**이다. 설계값은 담지 않는다 — +-- 설계 초안·계산 결과는 지금처럼 브라우저 세션에 두고 [저장]·[확정]에서만 정본으로 간다. +-- +-- 왜 필요한가 — 취향이 브라우저에만 있어 **PC 를 바꾸면 처음부터 다시 맞춰야 했다**. +-- 사용자가 노트북·데스크톱 두 대를 오가며 쓴다. (탭을 새로 열 때마다 초기화되던 것은 +-- 앞서 `localStorage` 로 옮겨 해결했고, 이 표는 그 위에 「다른 PC 에서도 같은 배치」를 얹는다.) +-- +-- 왜 칸을 나누지 않고 JSON 한 칸인가 — 취향은 화면이 늘 때마다 늘어난다. 칸으로 나누면 +-- 그때마다 마이그레이션이 또 필요하다. 값이 작고(수십 바이트) 검색 대상이 아니라 +-- 한 칸에 담는 편이 값싸다. 키 이름은 브라우저 등록표(`A00_Common/b_page_state.ts`)가 정본이다. + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS user_ui_prefs ( + user_id INT NOT NULL COMMENT 'users.id — 사용자당 한 줄', + prefs JSON NOT NULL COMMENT '화면 취향 묶음 {키: 값}. 키는 브라우저 등록표의 pref 이름', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id), + CONSTRAINT fk_user_ui_prefs_user FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='사용자별 화면 취향 (배치·표시). 설계값은 담지 않는다'; diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index fe396242..3fe0881b 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -5,7 +5,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; -import { storageOf } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -85,7 +85,7 @@ export function createWorkflowPanelHandle( } function readOpenState(key: string): boolean { - return storageOf(key).getItem(key) !== "false"; + return readByKey(key) !== "false"; } /** @@ -192,7 +192,7 @@ function createPanel( toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(isOpen)); } - storageOf(storageKey).setItem(storageKey, String(isOpen)); + writeByKey(storageKey, String(isOpen)); // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts index 3dc6cdbb..35e205cc 100644 --- a/ui_template/ui_template_overlay_drag.ts +++ b/ui_template/ui_template_overlay_drag.ts @@ -11,7 +11,7 @@ * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). */ -import { storageOf } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; const DRAG_THRESHOLD_PX = 4; /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ @@ -30,7 +30,7 @@ interface PanelPosition { } function readPosition(key: string): PanelPosition | null { - const raw = storageOf(key).getItem(key); + const raw = readByKey(key); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; @@ -94,7 +94,7 @@ export function makePanelDraggable( root.style.bottom = "auto"; root.style.top = `${next.top}px`; } - storageOf(storageKey).setItem(storageKey, JSON.stringify(next)); + writeByKey(storageKey, JSON.stringify(next)); } function swallowNextClick(): void { diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts index 717aec37..a25a8436 100644 --- a/ui_template/ui_template_resizer.ts +++ b/ui_template/ui_template_resizer.ts @@ -1,5 +1,5 @@ import "./ui_template_resizer.css"; -import { storageOf } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /* ============================================================================= * ui_template_resizer.ts @@ -62,20 +62,20 @@ export function createPanelResizer(options: PanelResizerOptions): PanelResizer { function apply(size: number, persist: boolean): void { const next = clamp(size); target.style.setProperty(cssVar, `${Math.round(next)}px`); - if (persist && storageKey) storageOf(storageKey).setItem(storageKey, String(Math.round(next))); + if (persist && storageKey) writeByKey(storageKey, String(Math.round(next))); onResize?.(next); } function restore(): void { if (!storageKey) return; - const saved = Number(storageOf(storageKey).getItem(storageKey)); + const saved = Number(readByKey(storageKey)); // 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다. if (Number.isFinite(saved) && saved > 0) apply(saved, false); } function reset(): void { target.style.removeProperty(cssVar); - if (storageKey) storageOf(storageKey).removeItem(storageKey); + if (storageKey) writeByKey(storageKey, null); onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth); } -- 2.54.0 From e43ad18f97a6d6535f81736ef8b5c7e875a13ac9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:54:18 +0900 Subject: [PATCH 272/441] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=A0=88?= =?UTF-8?q?=ED=86=A0=20=EC=82=AC=EB=A9=B4=EC=97=90=20=EC=86=8C=EB=8B=A8(?= =?UTF-8?q?=EA=B3=84=EB=8B=A8)=20=EA=B8=B0=ED=95=98=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=C2=B7TS=20?= =?UTF-8?q?=EC=A7=9D=20(=EA=B3=84=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦. 이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임. 새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦. 암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에 실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 가장 적게 파는 조합임. 기본값은 되돌리기 쉬운 쪽이어야 함 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 함. 실효 경사로도 그러함(경사 1:1 기준) 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → 1:1.71. · 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시). 무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이 바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐). 자체검증 - 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS 꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것, 소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인. - 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값), 폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일. - ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs 7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도 틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼. - 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을 안 바꿨다는 증거임. 전체 537 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 82 ++++----- common_util/common_util_cross_berm.py | 157 ++++++++++++++++++ common_util/common_util_cross_berm.ts | 152 +++++++++++++++++ .../common_util_cross_design_geometry.ts | 73 ++++---- 4 files changed, 373 insertions(+), 91 deletions(-) create mode 100644 common_util/common_util_cross_berm.py create mode 100644 common_util/common_util_cross_berm.ts diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7cf2e4f8..75290112 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -34,6 +34,11 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_cut_areas, _trapezoid_areas, ) +from common_util.common_util_cross_berm import ( + BermSpec, + cut_profile_points, +) +from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -164,6 +169,7 @@ class _SectionGeometry: ditch_enabled: bool | None = None, widening_left_m: float = 0.0, widening_right_m: float = 0.0, + berm: BermSpec | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). @@ -186,7 +192,9 @@ class _SectionGeometry: ) self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 - self._rock_knee: dict[str, tuple[float, float] | None] = {} + # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. + self.berm = berm + self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} @@ -275,52 +283,25 @@ class _SectionGeometry: assert self._ground_at is not None # two_stage일 때만 호출 return self._ground_at(signed) + self._rock_offset - def knee(self, side: str) -> tuple[float, float] | None: - """절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시). + def cut_points(self, side: str) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. - 노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터 - 토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면 - None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다. + 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). + 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). """ - if not self.two_stage: - return None - if side in self._rock_knee: - return self._rock_knee[side] + if side in self._cut_points_cache: + return self._cut_points_cache[side] start_dist, start_z = self._slope_start(side) - diff_prev = start_z - self._rock_boundary_z(side, start_dist) - result: tuple[float, float] | None - if diff_prev >= 0: - result = (start_dist, start_z) # 시작부터 토사(경계 위) - else: - result = None - step = 0.05 - dist_prev = start_dist - dist = start_dist + step - while dist <= start_dist + 200.0: - z_rock = start_z + (dist - start_dist) / self.cut_ratio - diff = z_rock - self._rock_boundary_z(side, dist) - if diff >= 0: - span = diff - diff_prev - ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0 - knee_dist = dist_prev + (dist - dist_prev) * ratio - knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio - result = (knee_dist, knee_z) - break - dist_prev, diff_prev = dist, diff - dist += step - self._rock_knee[side] = result - return result + boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None + points = cut_profile_points( + start_dist, start_z, self.cut_ratio, self.soil_cut_ratio, boundary, self.berm + ) + self._cut_points_cache[side] = points + return points def _cut_slope_z(self, side: str, dist: float) -> float: - """절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" - start_dist, start_z = self._slope_start(side) - knee = self.knee(side) if self.two_stage else None - if knee is not None: - knee_dist, knee_z = knee - if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 - return start_z + (dist - start_dist) / self.cut_ratio - return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만 - return start_z + (dist - start_dist) / self.cut_ratio + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -439,15 +420,20 @@ class _SectionGeometry: return max(fill_line, ground_m) def breakpoints(self) -> list[float]: - """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함).""" + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) - if self.two_stage: + # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if self.two_stage or self.berm is not None: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role - knee = self.knee(side) if role == "cut" else None - if knee is not None: - points.append(knee[0] if side == "left" else -knee[0]) + if role != "cut": + continue + cross = self.cut_cross_dist(side) + for offset, _z in self.cut_points(side): + if cross is not None and offset > cross + 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + points.append(offset if side == "left" else -offset) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role @@ -490,6 +476,7 @@ def compute_cross_design( plan_radius_m: float | None = None, curve_outer_side: str | None = None, curve_widening_m: float | None = None, + berm: BermSpec | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -577,6 +564,7 @@ def compute_cross_design( ditch_enabled=ditch_enabled, widening_left_m=widening_left, widening_right_m=widening_right, + berm=berm, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py new file mode 100644 index 00000000..d0f28997 --- /dev/null +++ b/common_util/common_util_cross_berm.py @@ -0,0 +1,157 @@ +"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). + +짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 +`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. + +**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. +지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 +겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신 +**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 +값이 나온다(거울 시험이 그것도 지킨다). + +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. + 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. +· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 + **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +""" + +import math +from typing import Callable, NamedTuple + +# 소단 기본값 — 근거는 위 모듈 설명. +BERM_DEFAULT_WIDTH_M = 0.5 +BERM_DEFAULT_INTERVAL_M = 3.0 +BERM_DEFAULT_SLOPE_DEG = 2.0 + +# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. +_STEP_M = 0.05 +_MAX_REACH_M = 200.0 + + +class BermSpec(NamedTuple): + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + + width_m: float = BERM_DEFAULT_WIDTH_M + interval_m: float = BERM_DEFAULT_INTERVAL_M + slope_deg: float = BERM_DEFAULT_SLOPE_DEG + + +def cut_profile_points( + start_dist: float, + start_z: float, + cut_ratio: float, + soil_cut_ratio: float, + rock_boundary_z: Callable[[float], float] | None, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. + + `rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다. + 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** + 꺾는다. + + ⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러 + 번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도 + 같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가 + 통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**. + + `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. + 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 + (물이 노면 쪽으로 흐르게 — 소단측구를 놓는 자리다). + + 꼭짓점만 돌려준다 — 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다. + """ + points: list[tuple[float, float]] = [(start_dist, start_z)] + dist, elevation = start_dist, start_z + slant_since_berm = 0.0 + limit = start_dist + max_reach_m + berm_rise = ( + math.tan(math.radians(berm.slope_deg)) * berm.width_m + if berm is not None and berm.width_m > 0 + else 0.0 + ) + # 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist) + ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio + + while dist < limit: + rise = _STEP_M / ratio + slant = math.hypot(_STEP_M, rise) + + # ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: + remain = max(berm.interval_m - slant_since_berm, 0.0) + run = remain / math.hypot(1.0, 1.0 / ratio) + dist += run + elevation += run / ratio + points.append((dist, elevation)) # 소단 안쪽 모서리 + dist += berm.width_m + elevation += berm_rise + points.append((dist, elevation)) # 소단 바깥 모서리 + slant_since_berm = 0.0 + continue + + next_dist = dist + _STEP_M + next_z = elevation + rise + + # ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if rock_boundary_z is not None and not in_soil: + diff_now = elevation - rock_boundary_z(dist) + diff_next = next_z - rock_boundary_z(next_dist) + if diff_next >= 0: + span = diff_next - diff_now + share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 + share = min(max(share, 0.0), 1.0) + knee_dist = dist + _STEP_M * share + knee_z = elevation + rise * share + slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) + dist, elevation = knee_dist, knee_z + points.append((dist, elevation)) # 무릎 + in_soil = True + ratio = soil_cut_ratio + continue + + dist, elevation = next_dist, next_z + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + +def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게.""" + out: list[tuple[float, float]] = [] + for point in points: + if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9: + continue + out.append(point) + return out + + +def elevation_at(points: list[tuple[float, float]], dist: float) -> float: + """꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장.""" + if not points: + return 0.0 + if dist <= points[0][0]: + return points[0][1] + for index in range(1, len(points)): + x0, z0 = points[index - 1] + x1, z1 = points[index] + if dist > x1 + 1e-12: + continue + span = x1 - x0 + if span <= 1e-12: + return z1 + return z0 + (z1 - z0) * ((dist - x0) / span) + # 끝을 넘어가면 마지막 두 점의 기울기로 잇는다. + x0, z0 = points[-2] if len(points) > 1 else points[-1] + x1, z1 = points[-1] + span = x1 - x0 + if span <= 1e-12: + return z1 + return z1 + (z1 - z0) / span * (dist - x1) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts new file mode 100644 index 00000000..d9a669b4 --- /dev/null +++ b/common_util/common_util_cross_berm.ts @@ -0,0 +1,152 @@ +/* ============================================================================= + * common_util/common_util_cross_berm.ts + * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) + * + * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. + * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * + * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. + * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. + * + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. + * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * ========================================================================== */ + +/** 소단 기본값 — 근거는 위 설명. */ +export const BERM_DEFAULT_WIDTH_M = 0.5; +export const BERM_DEFAULT_INTERVAL_M = 3.0; +export const BERM_DEFAULT_SLOPE_DEG = 2.0; + +/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ +const STEP_M = 0.05; +const MAX_REACH_M = 200.0; + +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +export interface BermSpec { + widthM: number; + intervalM: number; + slopeDeg: number; +} + +export function bermSpec( + widthM = BERM_DEFAULT_WIDTH_M, + intervalM = BERM_DEFAULT_INTERVAL_M, + slopeDeg = BERM_DEFAULT_SLOPE_DEG, +): BermSpec { + return { widthM, intervalM, slopeDeg }; +} + +/** + * 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로. + * + * `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는 + * 자리에서 암 → 토사로 **한 번** 꺾는다. + * + * ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는 + * 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07). + */ +export function cutProfilePoints( + startDist: number, + startZ: number, + cutRatio: number, + soilCutRatio: number, + rockBoundaryZ: ((dist: number) => number) | null, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, +): Array<[number, number]> { + const points: Array<[number, number]> = [[startDist, startZ]]; + let dist = startDist; + let elevation = startZ; + let slantSinceBerm = 0; + const limit = startDist + maxReachM; + const bermRise = + berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; + // 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); + let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; + + while (dist < limit) { + const rise = STEP_M / ratio; + const slant = Math.hypot(STEP_M, rise); + + // ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { + const remain = Math.max(berm.intervalM - slantSinceBerm, 0); + const run = remain / Math.hypot(1, 1 / ratio); + dist += run; + elevation += run / ratio; + points.push([dist, elevation]); // 소단 안쪽 모서리 + dist += berm.widthM; + elevation += bermRise; + points.push([dist, elevation]); // 소단 바깥 모서리 + slantSinceBerm = 0; + continue; + } + + const nextDist = dist + STEP_M; + const nextZ = elevation + rise; + + // ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. + if (rockBoundaryZ !== null && !inSoil) { + const diffNow = elevation - rockBoundaryZ(dist); + const diffNext = nextZ - rockBoundaryZ(nextDist); + if (diffNext >= 0) { + const span = diffNext - diffNow; + let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; + share = Math.min(Math.max(share, 0), 1); + const kneeDist = dist + STEP_M * share; + const kneeZ = elevation + rise * share; + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = true; + ratio = soilCutRatio; + continue; + } + } + + dist = nextDist; + elevation = nextZ; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + +/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ +function dedupe(points: Array<[number, number]>): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const point of points) { + const last = out[out.length - 1]; + if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) { + continue; + } + out.push(point); + } + return out; +} + +/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */ +export function elevationAt(points: Array<[number, number]>, dist: number): number { + if (points.length === 0) return 0; + if (dist <= points[0][0]) return points[0][1]; + for (let index = 1; index < points.length; index += 1) { + const [x0, z0] = points[index - 1]; + const [x1, z1] = points[index]; + if (dist > x1 + 1e-12) continue; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z0 + (z1 - z0) * ((dist - x0) / span); + } + const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1]; + const [x1, z1] = points[points.length - 1]; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z1 + ((z1 - z0) / span) * (dist - x1); +} diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 1ff67234..bb9cdbc1 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,9 +6,10 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ +import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; const CROSS_MAX_M = 500; export interface ResolvedGroup { @@ -87,7 +88,9 @@ export class SectionGeometry { ditchPoints: Array<[number, number]> = []; private groundAt: ((offsetM: number) => number) | null; private rockOffset: number; - private rockKnee = new Map(); + /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ + berm: BermSpec | null = null; + private cutPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -203,50 +206,27 @@ export class SectionGeometry { return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; } - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); + /** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */ + cutPoints(side: string): Array<[number, number]> { + const cached = this.cutPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; + const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null; + const points = cutProfilePoints( + startDist, + startZ, + this.cutRatio, + this.soilCutRatio, + boundary, + this.berm, + ); + this.cutPointsCache.set(side, points); + return points; } - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + /** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; + return elevationAt(this.cutPoints(side), dist); } /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ @@ -354,11 +334,16 @@ export class SectionGeometry { breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { + // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if (this.twoStage || this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + for (const [offset] of this.cutPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + points.push(side === "left" ? offset : -offset); + } } } for (const side of ["left", "right"]) { -- 2.54.0 From 5a6617f27888915524351f0650beb5fd25bf90b9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 14:58:18 +0900 Subject: [PATCH 273/441] =?UTF-8?q?fix(=ED=9A=A1=EB=8B=A8):=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=EC=9D=B4=20=EC=95=94=20=EA=B2=BD=EA=B3=84=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EB=A1=9C=20=EB=90=98=EB=8F=8C=EC=95=84=EA=B0=80?= =?UTF-8?q?=EB=A9=B4=20=EB=8B=A4=EC=8B=9C=20=EC=95=94=20=EA=B2=BD=EC=82=AC?= =?UTF-8?q?=EB=A1=9C=20=EA=B7=B8=EB=A6=BC=20(=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배분 창 지적으로 확인한 자리 — 미룰 수 없는 쪽이었음. 무엇이 문제였나 — 소단은 평탄한데 암 경계선은 지반을 따라 올라감. 지반이 1:1 이면 폭 0.5m 소단 하나를 지나는 동안 경계는 0.5m 오르고 소단은 2°(0.017m)만 오름. 그래서 설계선이 경계 아래로 **되돌아 들어가는 일이 흔함**. 무릎을 한 번만 꺾는 종전 규칙으로는 그 구간을 **암인데 토사 경사로** 그려 절토가 조용히 커짐 — 경고도 안 뜸. 고침 — `multi_knee` 를 둬서 경계를 오갈 때마다 꺾게 함. **소단이 있을 때만** 켬. 소단이 없을 때 켜면 지금 측점들의 설계가 같이 바뀌기 때문임(실측: 물결 경계 0.0016m). 기본값은 거짓이라 소단을 안 쓰는 프로젝트는 그대로임. 자체검증 - 새 시험 1건 — 경계를 뚫고 올라갔다가 소단마다 뒤처져 되돌아 들어가는 지형에서 한 번만 꺾는 결과와 갈리고, 되돌아 들어간 구간이 암 경사(가파름)라 같은 거리에서 더 높이 올라가는 것을 확인. - 거울 시험에 되돌아 들어가는 경우를 한 줄 더해 파이썬·TS 일치 확인(12건). - 전체 539 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 11 +++++++- common_util/common_util_cross_berm.py | 27 ++++++++++++------- common_util/common_util_cross_berm.ts | 13 +++++---- .../common_util_cross_design_geometry.ts | 3 +++ 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 75290112..2ea1e9ba 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -294,7 +294,16 @@ class _SectionGeometry: start_dist, start_z = self._slope_start(side) boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None points = cut_profile_points( - start_dist, start_z, self.cut_ratio, self.soil_cut_ratio, boundary, self.berm + start_dist, + start_z, + self.cut_ratio, + self.soil_cut_ratio, + boundary, + self.berm, + # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 + # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 + # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). + multi_knee=self.berm is not None, ) self._cut_points_cache[side] = points return points diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index d0f28997..02c69c73 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -48,6 +48,7 @@ def cut_profile_points( rock_boundary_z: Callable[[float], float] | None, berm: BermSpec | None, max_reach_m: float = _MAX_REACH_M, + multi_knee: bool = False, ) -> list[tuple[float, float]]: """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. @@ -55,10 +56,16 @@ def cut_profile_points( 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** 꺾는다. - ⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러 - 번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도 - 같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가 - 통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**. + `multi_knee` 가 거짓이면 **한 번만** 꺾는다(기존 규칙 그대로). 참이면 경계를 오갈 때마다 + 꺾는다. + + ⚠ **소단이 있으면 반드시 참이어야 한다.** 소단은 평탄한데 경계선은 지반을 따라 올라가므로, + 폭 0.5m 짜리 소단 하나만 지나도 설계선이 경계 **아래로 되돌아가는 일이 흔하다**(지반이 + 1:1 이면 경계는 0.5m 오르고 소단은 2°=0.017m 만 오른다). 한 번만 꺾으면 그 구간을 **암인데 + 토사 경사로** 그려 절토가 조용히 커진다 — 경고도 안 뜬다(2026-09-07 배분 창 지적으로 확인). + + 반대로 소단이 없을 때 참으로 두면 **지금 측점들의 설계가 같이 바뀐다**(실측: 물결 경계에서 + 0.0016m). 그래서 기본값은 거짓이고, 소단을 줄 때만 참으로 켠다. `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 @@ -99,11 +106,13 @@ def cut_profile_points( next_dist = dist + _STEP_M next_z = elevation + rise - # ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. - if rock_boundary_z is not None and not in_soil: + # ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + # `multi_knee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if rock_boundary_z is not None and (multi_knee or not in_soil): diff_now = elevation - rock_boundary_z(dist) diff_next = next_z - rock_boundary_z(next_dist) - if diff_next >= 0: + crossed = (diff_next >= 0) if not in_soil else (diff_next < 0) + if crossed: span = diff_next - diff_now share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 share = min(max(share, 0.0), 1.0) @@ -112,8 +121,8 @@ def cut_profile_points( slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) dist, elevation = knee_dist, knee_z points.append((dist, elevation)) # 무릎 - in_soil = True - ratio = soil_cut_ratio + in_soil = not in_soil + ratio = soil_cut_ratio if in_soil else cut_ratio continue dist, elevation = next_dist, next_z diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index d9a669b4..c4e0a192 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -57,6 +57,7 @@ export function cutProfilePoints( rockBoundaryZ: ((dist: number) => number) | null, berm: BermSpec | null, maxReachM: number = MAX_REACH_M, + multiKnee = false, ): Array<[number, number]> { const points: Array<[number, number]> = [[startDist, startZ]]; let dist = startDist; @@ -90,11 +91,13 @@ export function cutProfilePoints( const nextDist = dist + STEP_M; const nextZ = elevation + rise; - // ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다. - if (rockBoundaryZ !== null && !inSoil) { + // ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + // `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if (rockBoundaryZ !== null && (multiKnee || !inSoil)) { const diffNow = elevation - rockBoundaryZ(dist); const diffNext = nextZ - rockBoundaryZ(nextDist); - if (diffNext >= 0) { + const crossed = !inSoil ? diffNext >= 0 : diffNext < 0; + if (crossed) { const span = diffNext - diffNow; let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; share = Math.min(Math.max(share, 0), 1); @@ -104,8 +107,8 @@ export function cutProfilePoints( dist = kneeDist; elevation = kneeZ; points.push([dist, elevation]); // 무릎 - inSoil = true; - ratio = soilCutRatio; + inSoil = !inSoil; + ratio = inSoil ? soilCutRatio : cutRatio; continue; } } diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index bb9cdbc1..d9b9502f 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -219,6 +219,9 @@ export class SectionGeometry { this.soilCutRatio, boundary, this.berm, + undefined, + // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). + this.berm !== null, ); this.cutPointsCache.set(side, points); return points; -- 2.54.0 From 76fa78c5288d1cdeeecc8b58f52288760f0fc3da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 15:06:53 +0900 Subject: [PATCH 274/441] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=ED=98=95=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=9D=B4=20=EB=86=93?= =?UTF-8?q?=EC=9D=B8=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=EC=97=90=20=EB=8A=98=20=EB=9D=A0=EB=A1=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B군(측구·맹암거 등)을 넣어도 **어디에 놓였는지 안 보였음**. 알약은 기준점 한 곳에만 찍히고, 시~종점 띠는 **고른 동안만** 폈기 때문. 측구처럼 길게 이어지는 시설은 「어디부터 어디까지」가 곧 그 시설의 내용이라 늘 보여야 함(2026-09-07 사용자 지시). - 구간형이면 **늘** 띠를 그림. 고른 것은 두껍고 진하게(4px·0.55), 나머지는 얇고 옅게(3px·0.45). - **레인 높이는 42px 그대로** — 2-5b 에서 줄여 둔 것을 안 부풀림. - B05 가 레지스트리를 **한 번만 받아 쓰는 자리에 따라 나눔** — 추가 메뉴는 켜진 종류만 (종전대로 여섯 군), **알약 레인·군 판정은 전부**. B06 에서 넣은 B군은 레지스트리에서 꺼져 있어, 켜진 것만 주면 종단에 이름·색 없이 「?」 회색 알약으로 떴을 자리였음. 실측(용화, 구간형 7건) — 띠 **0 → 7개**, 높이 3px, 레인 42px 그대로, 알약 18개 그대로. B군 6종은 전부 `interval` 이라 확인한 C군과 같은 경로를 탐. 타입 응답 36종에 B군 6종 포함 확인. 평면(지도)은 손대지 않음 — 노선 위에 구간을 얹는 방법이 지금 없어 **별건**으로 둠. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Page_Structures.ts | 15 ++++++++++----- B05_Profile/B05_Profile_UI_Structures_Marks.ts | 12 ++++++++---- B05_Profile/B05_Profile_UI_Style_Structures.css | 11 ++++++++++- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 30407218..b90159f4 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -301,15 +301,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */ async function loadStructures(): Promise { try { - const [types, stored] = await Promise.all([ - fetchStructureTypes(), + // 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07). + // · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군). + // · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서 + // 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다. + const [allTypes, stored] = await Promise.all([ + fetchStructureTypes(true), fetchStructures(deps.projectId), ]); - deps.panel().structures.setTypes(types); - deps.profilePanel().setStructureTypes(types); + const menuTypes = allTypes.filter((type) => type.enabled); + deps.panel().structures.setTypes(menuTypes); + deps.profilePanel().setStructureTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( - types.map((type) => [type.type_id, { group: type.group, name: type.name }]), + allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]), ); structureRevision = stored.revision; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 1505a056..1c52a483 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -157,15 +157,19 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement ? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2 : LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX); - // 구간형은 고른 동안만 시~종점을 띠로 펼친다. - if (selected && structure.placement === "interval") { + // 구간형은 시~종점을 **늘 띠로** 펼친다(2026-09-07 사용자 지시 — B군을 넣어도 어디에 + // 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는 + // 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다. + // 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다. + if (structure.placement === "interval") { const band = document.createElement("div"); - band.className = "b05-structure__band"; + band.className = `b05-structure__band${selected ? " is-selected" : ""}`; const startPx = options.x(structure.start_m ?? 0); const endPx = options.x(structure.end_m ?? 0); band.style.left = `${Math.min(startPx, endPx)}px`; band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`; - band.style.top = `${top + height / 2 - 1}px`; + // 알약 세로 가운데에 맞춘다 — 띠 두께가 골랐을 때만 4px 라 반값이 다르다. + band.style.top = `${top + height / 2 - (selected ? 2 : 1.5)}px`; band.style.background = type?.style?.color ?? "#888"; lane.append(band); } diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 0097f181..e9c3b4f9 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -86,12 +86,21 @@ } /* 구간 띠 — 고른 동안에만 시~종점을 펼쳐 보여 준다. */ +/* 구간형 시설이 놓인 자리 — 늘 보인다(2026-09-07). 고르지 않은 것은 옅은 실선으로 깔려 + 있고, 고른 것만 두껍고 진해진다. 길게 이어지는 시설(측구·맹암거)이 많아도 알약을 가리지 + 않도록 얇게 둔다. */ .b05-structure__band { position: absolute; + height: 3px; + border-radius: 1.5px; + opacity: 0.45; + pointer-events: none; +} + +.b05-structure__band.is-selected { height: 4px; border-radius: 2px; opacity: 0.55; - pointer-events: none; } /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ -- 2.54.0 From 061d550e552998de3fb23e9b020a193cde1a0d85 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 15:28:16 +0900 Subject: [PATCH 275/441] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=86=8C?= =?UTF-8?q?=EB=8B=A8=EC=9D=84=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=C2=B7?= =?UTF-8?q?=EC=9E=AC=EA=B3=84=EC=82=B0=EC=97=90=20=EC=8B=A4=EC=96=B4=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B3=84=EB=8B=A8=EC=9D=B4=20?= =?UTF-8?q?=EC=84=9C=EA=B2=8C=20=ED=95=A8=20(=EA=B3=84=ED=9A=8D=EC=84=9C?= =?UTF-8?q?=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계단이 값에만 있던 것을 화면까지 연결함. 배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고, `readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과 ② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음. 암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음. 실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고 계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움. · 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86) · 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임** · 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐 · 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로. `cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝). 법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는 1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음. 자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인. 전체 539 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 ++ B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Cross_Refresh.ts | 14 +++- B06_Section/B06_Section_Engine_Design.py | 61 +++++++++++++++++ B06_Section/B06_Section_Router.py | 1 + B06_Section/B06_Section_Router_Design.py | 19 ++++++ B06_Section/B06_Section_Router_HaulPlan.py | 4 +- B06_Section/B06_Section_Schema.py | 4 ++ B06_Section/B06_Section_UI_Page_Persist.ts | 21 ++++++ common_util/common_util_cross_design.ts | 8 +++ .../common_util_cross_design_geometry.ts | 68 +++++++++++++++++++ 11 files changed, 203 insertions(+), 4 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 59904f2b..ed3dff06 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. + * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 + * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ + berm: { bucket: "draft", scope: "route" }, /** 표준 횡단면 설정 패널의 편집값. * * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 626813ba..26e84d89 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -253,6 +253,8 @@ export async function previewCrossDesigns( fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; + /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ + berms?: Record; }, ): Promise { return requestJson( @@ -264,6 +266,7 @@ export async function previewCrossDesigns( standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + berms: options?.berms ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 5305e28d..602f9b3c 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,8 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, @@ -162,6 +163,15 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). + const berms = readBermSession(projectId, input.routeId); + const bermAt = (chainageM: number): BermSpec | null => { + const spec = berms[rockKey(chainageM).toFixed(2)]; + return spec + ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + : null; + }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). const choices = crossDesignChoices(projectId, input.routeId); @@ -204,6 +214,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), paved: choice?.paved ?? Boolean(design.paved), standard, + berm: bermAt(section.chainage_m), rockBoundaryOffsetM, twoStageSlope: choice?.two_stage_slope ?? @@ -243,6 +254,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + berms: readBermSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 2ea1e9ba..8a0fcf11 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,6 +27,7 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ +import math from collections.abc import Callable from typing import Any @@ -312,6 +313,64 @@ class _SectionGeometry: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.cut_points(side), dist) + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. + + 왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 + **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는 + 소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다. + + · 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다. + · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. + · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. + 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). + """ + segments: list[dict[str, Any]] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + points = self.cut_points(side) + sign = 1.0 if side == "left" else -1.0 + for index in range(1, len(points)): + start_d, start_z = points[index - 1] + end_d, end_z = points[index] + if cross is not None and start_d >= cross - 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + if cross is not None and end_d > cross: + # 지반과 만나는 점에서 구간을 자른다. + end_z = berm_elevation_at(points, cross) + end_d = cross + run = end_d - start_d + rise = end_z - start_z + if run <= 1e-9 or rise <= 1e-6: + continue # 길이 0·역방향은 검사 대상이 아니다 + if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: + # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. + berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m + if abs(rise - berm_rise) < 1e-9: + continue + material: str | None = None + if self.two_stage: + middle_d = (start_d + end_d) / 2.0 + middle_z = (start_z + end_z) / 2.0 + material = ( + "soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock" + ) + segments.append( + { + "side": side, + "ratio": round(run / rise, 4), + "rise_m": round(rise, 4), + "run_m": round(run, 4), + "start_offset_m": round(sign * start_d, 4), + "end_offset_m": round(sign * end_d, 4), + "material": material, + } + ) + return segments + def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -715,6 +774,8 @@ def compute_cross_design( ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, + # 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외). + "cut_slope_segments": geometry.cut_slope_segments(), } if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index c497a156..ff330c5b 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -553,6 +553,7 @@ async def preview_cross_designs( request.standard_cross_section, request.rock_boundary_offsets, project_root, + request.berms, ) await asyncio.to_thread(rebuild) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 2ca30aea..6cd07943 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -10,6 +10,12 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_cross_berm import ( + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + BermSpec, +) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -343,6 +349,7 @@ def recompute_designs_for_alignment( standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, + berms: dict[str, dict[str, float]] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -358,6 +365,17 @@ def recompute_designs_for_alignment( session_offsets[round(float(raw_key), 3)] = float(offset) except (TypeError, ValueError): continue + # 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로). + session_berms: dict[float, BermSpec] = {} + for raw_key, spec in (berms or {}).items(): + try: + session_berms[round(float(raw_key), 3)] = BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError, AttributeError): + continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) key = round(chainage, 3) @@ -380,6 +398,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=session_berms.get(key), **curve_widening_args(section), ) except (ValueError, KeyError): diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py index 21f2fdbe..77c26186 100644 --- a/B06_Section/B06_Section_Router_HaulPlan.py +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -64,9 +64,7 @@ async def compute_haul_plan( {"haul_plan_for": result, "context": _mass_haul_context()}, ) except Exception: - logger.exception( - "유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id - ) + logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) return JSONResponse( status_code=500, content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index db92a93d..9b8dac61 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -298,6 +298,10 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). + # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. + berms: dict[str, dict[str, float]] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 5c90c6fa..0642671d 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -52,6 +52,27 @@ export function readRockBoundarySession( return stored && typeof stored === "object" ? stored : {}; } +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; +} + +/** + * 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체. + * + * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 + * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). + */ +export function readBermSession( + projectId: string, + routeId: number, +): Record { + const stored = readState>("berm", projectId, routeId); + return stored && typeof stored === "object" ? stored : {}; +} + /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ export interface RockBoundaryStore { /** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */ diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 0cfe08ea..83f060b0 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,10 +23,12 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ +import type { BermSpec } from "./common_util_cross_berm"; import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). import { CURVE_WIDENING_MAX_WIDTH_M, + type CutSlopeSegment, SectionGeometry, curveWideningM, type ResolvedGroup, @@ -88,6 +90,8 @@ export interface CrossDesignOptions { curveOuterSide?: "left" | "right" | null; /** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */ curveWideningM?: number | null; + /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; } export interface CrossDesignEdge { @@ -128,6 +132,8 @@ export interface CrossDesignResult { fill_ground_slope: number | null; ditch_area_m2: number; design_line: CrossDesignEdge[]; + /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ + cut_slope_segments: CutSlopeSegment[]; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -298,6 +304,7 @@ export function computeCrossDesign( rockBoundaryOffsetM, twoStageSlope: enableTwoStage, ditchEnabled: options.ditchEnabled ?? null, + berm: options.berm ?? null, }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -430,6 +437,7 @@ export function computeCrossDesign( fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), ditch_area_m2: round4(ditchArea), design_line: designLine, + cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index d9b9502f..87d84ae8 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -8,6 +8,22 @@ import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; +/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ +export interface CutSlopeSegment { + side: string; + ratio: number; + rise_m: number; + run_m: number; + start_offset_m: number; + end_offset_m: number; + material: string | null; +} + +/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */ +function round4(value: number): number { + return Math.round(value * 10000) / 10000; +} + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; const CROSS_MAX_M = 500; @@ -109,6 +125,8 @@ export class SectionGeometry { /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ wideningLeftM?: number; wideningRightM?: number; + /** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; @@ -127,6 +145,7 @@ export class SectionGeometry { params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, ); this.groundAt = params.groundAt; + this.berm = params.berm ?? null; this.rockOffset = params.rockBoundaryOffsetM ?? 0; this.ditchType = params.ditchType; // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). @@ -333,6 +352,55 @@ export class SectionGeometry { return Math.max(startZ - run / this.fillRatio, groundM); } + /** + * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사용. + * + * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 + * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. + */ + cutSlopeSegments(): CutSlopeSegment[] { + const segments: CutSlopeSegment[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + const points = this.cutPoints(side); + const sign = side === "left" ? 1 : -1; + for (let index = 1; index < points.length; index += 1) { + const [startD, startZ] = points[index - 1]; + let [endD, endZ] = points[index]; + if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + if (cross !== null && endD > cross) { + endZ = elevationAt(points, cross); + endD = cross; + } + const run = endD - startD; + const rise = endZ - startZ; + if (run <= 1e-9 || rise <= 1e-6) continue; + if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) { + const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM; + if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부) + } + let material: string | null = null; + if (this.twoStage) { + const middleD = (startD + endD) / 2; + const middleZ = (startZ + endZ) / 2; + material = middleZ >= this.rockBoundaryZ(side, middleD) ? "soil" : "rock"; + } + segments.push({ + side, + ratio: round4(run / rise), + rise_m: round4(rise), + run_m: round4(run), + start_offset_m: round4(sign * startD), + end_offset_m: round4(sign * endD), + material, + }); + } + } + return segments; + } + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; -- 2.54.0 From 819aa643dc80141442de05eea6b0fd6442e8242b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:19:32 +0900 Subject: [PATCH 276/441] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20[=EC=86=8C=EB=8B=A8]=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=E2=80=94=20=EA=B5=AC=EA=B0=84=EC=97=90=20=EB=86=93=EA=B3=A0=20?= =?UTF-8?q?=EB=B9=BC=EA=B8=B0=20+=20=ED=99=95=EC=A0=95=20=EB=92=A4?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EB=82=A8=EA=B2=8C=20=ED=95=A8=20(?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 소단을 **직접 놓는** 화면을 만들었음. 프로그램이 「붕괴 우려 지역」을 판정하지 않고, 법정 기준 셋 중 무엇도 자동 적용하지 않음(2026-09-07 사용자 확정). 폼은 C군 구간형 구조물과 **같은 꼴** — 기준 측점 + 전·후 거리로 종단 범위를 잡고 폭·간격·기울기를 함께 받음. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 2°. 새 모듈 `B06_Section_UI_Berm_Panel.ts` 로 뺐음 — 좌측 페이지가 이미 700줄을 넘어 있어 거기 더 넣지 않고 두 줄만 꽂음(그 파일 분리는 별건). 세션에는 **구간 목록**으로 둠. 측점별로 펴서 저장하면 「어디부터 어디까지 놓았나」를 되짚을 수 없음. 읽는 자리에서 펴고, 서버에는 측점키 dict 로 실어 보냄. **확정 뒤에도 계단이 남게** — 소단 제원을 설계 결과에 되싣고(`berm`), `USER_TOUCHED_KEYS` 에 넣어 재계산이 지우지 않게 함. 서버 재계산은 세션값이 없으면 저장분을 씀(`stored_berm`). ⚠ 소단은 사용자 조작값이면서 **기하 입력**이라 다른 사용자 키와 다름 — 계산 뒤에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 서로 어긋남. 그래서 단측점 설계 경로는 저장분을 **계산 전에** 읽도록 순서를 바꿨고, 포장 강제·세월교 노면 하강 재계산 경로에도 저장분 소단을 실었음. `extra_spans` 가 그렇게 빠져 있던 자리와 같음. 자체검증 — 새 시험 2건(저장분만으로 같은 설계가 나오는지 · 값이 없거나 손상되면 None). 전체 541 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Cross_Refresh.ts | 29 +++- B06_Section/B06_Section_Engine_Design.py | 8 + B06_Section/B06_Section_Router.py | 34 ++-- B06_Section/B06_Section_Router_Design.py | 21 ++- B06_Section/B06_Section_UI_Berm_Panel.ts | 185 +++++++++++++++++++++ B06_Section/B06_Section_UI_Page.ts | 19 ++- B06_Section/B06_Section_UI_Page_Persist.ts | 41 ++++- 7 files changed, 303 insertions(+), 34 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Berm_Panel.ts diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 602f9b3c..6509d231 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,7 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { @@ -81,6 +81,8 @@ export const USER_TOUCHED_KEYS = [ "extra_spans", "revet_link_detached", "revet_follow_grade", + // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -133,6 +135,25 @@ function readRockOffsets(projectId: string, routeId: number): Map { + const spans = readBermSpans(projectId, routeId); + const out: Record = {}; + if (!spans.length) return out; + for (const section of detail.cross_sections) { + const spec = bermSpecAt(spans, section.chainage_m); + if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -165,9 +186,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockDefault = readRockBoundaryDefault(projectId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). - const berms = readBermSession(projectId, input.routeId); + const bermSpans = readBermSpans(projectId, input.routeId); const bermAt = (chainageM: number): BermSpec | null => { - const spec = berms[rockKey(chainageM).toFixed(2)]; + const spec = bermSpecAt(bermSpans, chainageM); return spec ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } : null; @@ -254,7 +275,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), - berms: readBermSession(projectId, routeId), + berms: bermPayload(projectId, routeId, detail), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 8a0fcf11..367b2bec 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -780,6 +780,14 @@ def compute_cross_design( if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) + if berm is not None: + # 소단 제원을 설계에 되싣는다 — 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 + # 계단이 다시 서야 한다. 암 경계선 오프셋을 echo 하는 것과 같은 까닭이다. + result["berm"] = { + "width_m": round(float(berm.width_m), 4), + "interval_m": round(float(berm.interval_m), 4), + "slope_deg": round(float(berm.slope_deg), 4), + } if paved: result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4) # 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index ff330c5b..006ad692 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -43,6 +43,7 @@ from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, + stored_berm, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -607,6 +608,16 @@ async def compute_cross_section_design( content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, ) stored_path = await get_project_storage_relative_path(connection, project_id) + # 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라, + # 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 + # 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다. + stored_designs = await get_cross_section_designs(connection, route_id) + stored_design: dict[str, Any] | None = None + for record in stored_designs: + if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: + candidate = record.get("design") + stored_design = candidate if isinstance(candidate, dict) else None + break project_root = Path(resolve_stored_project_path(stored_path)) samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread( _read_cross_design_inputs, @@ -627,27 +638,22 @@ async def compute_cross_section_design( two_stage_slope=request.two_stage_slope, ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), + berm=stored_berm(stored_design or {}), **curve_widening_args(cross_record), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" # 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다. design["pavement_suggested"] = pavement_suggested + # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 + # 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은 + # `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에 + # 따로 적어 두어 `extra_spans` 가 빠져 있었다. + if stored_design is not None: + for key in USER_TOUCHED_KEYS: + if stored_design.get(key) is not None: + design[key] = stored_design[key] async with pool.acquire() as connection: - # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 - # 지우지 않게 한다(2026-08-06). - stored_designs = await get_cross_section_designs(connection, route_id) - for record in stored_designs: - if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: - stored_design = record.get("design") - if isinstance(stored_design, dict): - # 목록을 여기 다시 적지 않는다 — 서버의 한 벌은 - # `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). - # 예전에는 여기에 따로 적어 두어 `extra_spans` 가 빠져 있었다. - for key in USER_TOUCHED_KEYS: - if stored_design.get(key) is not None: - design[key] = stored_design[key] - break await connection.begin() try: updated = await update_cross_section_design( diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 6cd07943..f94d15e0 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -138,6 +138,8 @@ USER_TOUCHED_KEYS = ( "extra_spans", "revet_link_detached", "revet_follow_grade", + # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ) @@ -193,6 +195,7 @@ def enforce_pavement_ranges( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): @@ -244,6 +247,7 @@ def enforce_ford_surface_drops( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, + berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): @@ -342,6 +346,21 @@ def attach_default_designs( continue +def stored_berm(stored: dict[str, Any]) -> BermSpec | None: + """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" + spec = stored.get("berm") + if not isinstance(spec, dict): + return None + try: + return BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError): + return None + + def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -398,7 +417,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), - berm=session_berms.get(key), + berm=session_berms.get(key) or stored_berm(stored), **curve_widening_args(section), ) except (ValueError, KeyError): diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts new file mode 100644 index 00000000..4ee00280 --- /dev/null +++ b/B06_Section/B06_Section_UI_Berm_Panel.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B06_Section_UI_Berm_Panel.ts + * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). + * + * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 + * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 + * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 + * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. + * + * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, + * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 + * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). + * ========================================================================== */ + +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; +import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; +import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; +import { buildGroup } from "./B06_Section_UI_Page_Common"; +import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; + +/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ +const DEFAULT_BEFORE_M = 5; +const DEFAULT_AFTER_M = 5; + +export interface BermPanelDeps { + /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ + getInterval: () => number; + /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ + target: () => { projectId: string; routeId: number } | null; + /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ + onChange: () => void; +} + +export interface BermPanel { + root: HTMLElement; + /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ + reload: () => void; +} + +function numberField( + label: string, + value: number, + step: string, +): ReturnType { + const field = createInputField({ label, type: "number" }); + field.input.step = step; + field.input.min = "0"; + field.input.value = String(value); + return field; +} + +export function createBermPanel(deps: BermPanelDeps): BermPanel { + const root = buildGroup("소단"); + + const anchor = stationFields("기준 측점", () => deps.getInterval()); + const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); + const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); + const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); + const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); + const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); + + const spanRow = document.createElement("div"); + spanRow.className = "b05-structure__grid"; + spanRow.append(beforeField.root, afterField.root); + + const specRow = document.createElement("div"); + specRow.className = "b05-structure__grid"; + specRow.append(widthField.root, intervalField.root); + + const slopeRow = document.createElement("div"); + slopeRow.className = "b05-structure__grid"; + slopeRow.append(slopeField.root); + + const list = document.createElement("ul"); + list.className = "b05-route__irregular-list"; + + let selected = -1; + + const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); + const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); + removeButton.classList.add("is-danger"); + removeButton.disabled = true; + const actions = document.createElement("div"); + actions.className = "b06-profile__field-row"; + actions.append(addButton, removeButton); + + root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); + + function spans(): BermSpan[] { + const target = deps.target(); + return target ? readBermSpans(target.projectId, target.routeId) : []; + } + + function save(next: BermSpan[]): void { + const target = deps.target(); + if (!target) return; + writeBermSpans(target.projectId, target.routeId, next); + render(); + deps.onChange(); + } + + function readNumber(field: { input: HTMLInputElement }, fallback: number): number { + const parsed = Number(field.input.value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + function add(): void { + const interval = deps.getInterval(); + const chainage = anchor.read(interval, true); + if (chainage === null) { + showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); + return; + } + const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); + const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || gap <= 0) { + showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); + return; + } + const before = readNumber(beforeField, DEFAULT_BEFORE_M); + const after = readNumber(afterField, DEFAULT_AFTER_M); + save([ + ...spans(), + { + start_m: Math.max(chainage - before, 0), + end_m: chainage + after, + width_m: width, + interval_m: gap, + slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), + }, + ]); + selected = -1; + } + + function remove(): void { + if (selected < 0) return; + const next = spans().filter((_span, index) => index !== selected); + selected = -1; + save(next); + } + + function render(): void { + const interval = deps.getInterval(); + const current = spans(); + list.replaceChildren(); + removeButton.disabled = selected < 0 || selected >= current.length; + if (!current.length) { + const empty = document.createElement("li"); + empty.className = "b05-route__irregular-empty"; + empty.textContent = "놓은 소단이 없습니다."; + list.append(empty); + return; + } + current.forEach((span, index) => { + const item = document.createElement("li"); + item.className = "b05-route__irregular-item"; + item.classList.toggle("is-selected", index === selected); + const where = document.createElement("strong"); + where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; + const what = document.createElement("span"); + what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; + item.append(where, what); + item.addEventListener("click", () => { + selected = index === selected ? -1 : index; + render(); + }); + list.append(item); + }); + } + + render(); + + return { + root, + reload(): void { + selected = -1; + render(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3f2f28b7..f3b540eb 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,10 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import type { - StructureInstance, - StructureType, -} from "../B05_Profile/B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -26,6 +23,7 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; +import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, createRockBoundaryStore, @@ -144,8 +142,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. let structureMarksSink: - | ((structures: StructureInstance[], types: StructureType[]) => void) - | null = null; + ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). @@ -209,7 +206,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); + // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). + const bermPanel = createBermPanel({ + getInterval: () => stationInterval ?? 20, + target: () => + projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, + onChange: () => void reconcileStaleDesigns({ force: true }), + }); + leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -719,6 +723,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); + bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0642671d..028af1b0 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; -import { readState } from "../A00_Common/b_page_state"; +import { readState, writeState } from "../A00_Common/b_page_state"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -60,17 +60,42 @@ export interface BermSessionSpec { } /** - * 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체. + * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. + * + * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). + * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 + * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. + */ +export interface BermSpan extends BermSessionSpec { + start_m: number; + end_m: number; +} + +/** + * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. * * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). */ -export function readBermSession( - projectId: string, - routeId: number, -): Record { - const stored = readState>("berm", projectId, routeId); - return stored && typeof stored === "object" ? stored : {}; +export function readBermSpans(projectId: string, routeId: number): BermSpan[] { + const stored = readState("berm", projectId, routeId); + return Array.isArray(stored) ? stored : []; +} + +export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void { + writeState("berm", spans, projectId, routeId); +} + +/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ +export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { + const found = spans.find( + (span) => + chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && + chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, + ); + return found + ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } + : null; } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ -- 2.54.0 From 973128f798429a3a5c8b0c04799a8ed8a9cbaa1f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:20:42 +0900 Subject: [PATCH 277/441] =?UTF-8?q?feat(B06):=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EB=B9=84=ED=83=88=20=EB=B2=95=EC=A0=95=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=80=EC=82=AC=20=E2=80=94=20=EB=B3=84=ED=91=9C?= =?UTF-8?q?2=20=EB=B2=94=EC=9C=84=20=EB=B0=96=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EC=97=90=20=EA=B2=BD=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 법정 위반 표시가 없던 유일한 자리였음(평면 곡선반경·성토사면 길이는 이미 있음). 절토 경사비는 표준 횡단면 설정에서 오는 입력값일 뿐이라 범위를 벗어나도 아무 표시가 없었음. - 기준 — 별표2(지식DB `01_임도/02_상세설계/절토_비탈면.md` §1): 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. **작업임도는 규정 없음 → 검사 제외.** - 판정은 **구간별 경사**(`cut_slope_segments`)로 함. 소단이 서면 실효 경사가 완만해져 **위반이 사라진 것처럼** 보임(폭 1.0·간격 2 이면 1:1 이 1:1.71). 그 함정을 시험으로 못박음. - 표시는 **기존 방식 그대로** — 성토사면·미폐합 경고와 같은 자리·같은 클래스. 새 방식 안 만듦. - 지반유형(리핑암·발파암) → 별표2 줄 매핑은 법령 근거가 아니라 **프로그램 설정**이라 표준 횡단면 설정에서 **사용자가 고르게** 함(2026-09-07 사용자 확정). 기본값 리핑암 → 연암 · 발파암 → 경암. 표준단면과 함께 저장돼 이미 사용자 값 계통임. - 기준값은 서버가 컨텍스트로 내려보냄 — 화면에 상수를 복제하지 않음. 시험 7건 — 별표2 값 · 작업임도 제외 · 매핑이 기본값일 뿐 · 구간별 판정 · **소단이 있어도 위반이 안 사라짐** · 경계값 통과 · 카드에 같은 방식으로 붙는지. ⚠ 실화면 — 사용자 선택 칸은 섰음(리핑암 연암 · 발파암 경암). 다만 이 프로젝트 저장분 63측점에 `cut_slope_segments` 가 아직 없어(오늘 새로 생긴 키) 경고가 뜨는 것은 못 봄. 재계산이 한 번 돌면 채워짐 — 지금 경고 0건은 맞는 동작임. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 16 +- B06_Section/B06_Section_Cut_Slope_Check.ts | 137 ++++++++++++++++++ B06_Section/B06_Section_Router.py | 8 + B06_Section/B06_Section_Schema.py | 5 + .../B06_Section_UI_Cross_Card_Chrome.ts | 21 +++ B06_Section/B06_Section_UI_Page.ts | 3 + B06_Section/B06_Section_UI_Standard_Panel.ts | 41 +++++- config/config_system_design.py | 23 +++ 8 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 B06_Section/B06_Section_Cut_Slope_Check.ts diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 42dacd1b..590c76c5 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -45,7 +45,14 @@ export interface StandardCrossGroup { /** 표준 횡단면 설정 패널 그룹 키. */ export type StandardCrossKey = "soil" | "rock" | "paved"; -export type StandardCrossSection = Record; +export type StandardCrossSection = Record & { + /** 절토 법정 기울기 판정에 쓸 별표2 줄 — 지반유형(리핑암·발파암) → `soft_rock`/`hard_rock`. + * + * 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가르는데 둘을 잇는 + * 문장이 법령·교본에 없다. 그래서 법정 근거가 아니라 **사용자가 고르는 설정**으로 두고 + * 표준단면 설정과 함께 저장한다(2026-09-07 사용자 확정). 없으면 기본값을 쓴다. */ + cut_slope_class?: Record; +}; // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 // `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지). @@ -77,6 +84,13 @@ export interface SectionContextResponse { /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ stored_standard_cross_section?: StandardCrossSection | null; + /** 절토 비탈 법정 기울기 범위(별표2) — `hard_rock`·`soft_rock`·`soil` → [최소, 최대]. + * 상수를 화면에 복제하지 않으려고 **서버가 준 값을 그대로** 쓴다(2026-09-07). */ + cut_slope_limits?: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. **사용자가 바꿀 수 있는 설정값**의 기본값이다. */ + cut_slope_class_default?: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + cut_slope_exempt_grades?: string[]; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; diff --git a/B06_Section/B06_Section_Cut_Slope_Check.ts b/B06_Section/B06_Section_Cut_Slope_Check.ts new file mode 100644 index 00000000..ba64fa88 --- /dev/null +++ b/B06_Section/B06_Section_Cut_Slope_Check.ts @@ -0,0 +1,137 @@ +/* ============================================================================= + * B06_Section_Cut_Slope_Check.ts + * 절토 비탈 **법정 기울기 검사**(별표2) — 순수 판정만 둔다(그리기는 카드 몫). + * + * 왜 필요한가 — 평면 곡선반경도, 성토사면 길이(5m·기슭막이 의무)도 위반 표시가 있는데 + * **절토 경사만 검사가 없었다**(2026-09-07). 절토 경사비는 표준 횡단면 설정에서 오는 + * 입력값일 뿐이라 별표2 범위를 벗어나도 아무 표시가 없었다. + * + * ⚠ **실효 경사로 보면 안 된다.** 소단이 서면 사면 전체를 하나로 잰 경사가 완만해져 + * **위반이 사라진 것처럼** 보인다(실측: 폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). + * 그래서 `design.cut_slope_segments`(소단을 뺀 **구간별** 경사)를 읽는다. + * + * 기준값·매핑은 **서버가 준 값**을 쓴다(config 상수를 화면에 복제하지 않는다). + * ========================================================================== */ + +import type { CrossSection, SectionContextResponse } from "./B06_Section_Api_Fetch"; + +/** 별표2 한 줄 — 수직 1 에 대한 수평(1:n 의 n) 범위. */ +export interface CutSlopeLimit { + min: number; + max: number; +} + +export interface CutSlopeCriteria { + /** 별표2 범위표 — `hard_rock`·`soft_rock`·`soil`. */ + limits: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. 사용자가 바꿀 수 있는 **설정값**이다. */ + classOf: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + exemptGrades: ReadonlyArray; +} + +export interface CutSlopeViolation { + side: string; + ratio: number; + /** 어느 별표2 줄로 판정했나 — 툴팁에 적는다. */ + limitKey: string; + limit: CutSlopeLimit; + /** 급한 쪽 위반인지(`steep`) 완만한 쪽 위반인지(`gentle`). */ + kind: "steep" | "gentle"; + startOffsetM: number; + endOffsetM: number; +} + +/** 서버 컨텍스트에서 기준을 꺼낸다. 값이 안 왔으면 null — 검사를 하지 않는다. */ +export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCriteria | null { + const limits = context?.cut_slope_limits; + if (!limits || !Object.keys(limits).length) return null; + const table: Record = {}; + for (const [key, pair] of Object.entries(limits)) { + if (Array.isArray(pair) && pair.length >= 2) table[key] = { min: pair[0], max: pair[1] }; + } + return { + limits: table, + classOf: context?.cut_slope_class_default ?? {}, + exemptGrades: context?.cut_slope_exempt_grades ?? [], + }; +} + +/** + * 이 측점의 절토 구간이 별표2 범위를 벗어났는지. + * + * · 등급이 면제(작업임도)면 빈 목록. + * · 구간이 없으면(성토 측점 등) 빈 목록. + * · 암 구간은 측점의 `cut_rock_kind` 로 별표2 줄을 고른다 — 구간에는 `rock`/`soil` 만 있다. + */ +export function cutSlopeViolations( + section: CrossSection, + criteria: CutSlopeCriteria | null, + gradeClass: string | null, +): CutSlopeViolation[] { + if (!criteria) return []; + if (gradeClass && criteria.exemptGrades.includes(gradeClass)) return []; + const design = section.design as + { cut_slope_segments?: unknown; cut_rock_kind?: string | null } | undefined; + const segments = design?.cut_slope_segments; + if (!Array.isArray(segments) || !segments.length) return []; + + const rockKey = criteria.classOf[design?.cut_rock_kind ?? ""] ?? "hard_rock"; + const out: CutSlopeViolation[] = []; + for (const raw of segments) { + const segment = raw as { + side?: string; + ratio?: number; + material?: string | null; + start_offset_m?: number; + end_offset_m?: number; + }; + const ratio = segment.ratio; + if (typeof ratio !== "number" || !Number.isFinite(ratio)) continue; + const limitKey = segment.material === "rock" ? rockKey : "soil"; + const limit = criteria.limits[limitKey]; + if (!limit) continue; + // 경계값은 통과 — 1:0.8 은 「0.3~0.8」 안이다. + if (ratio >= limit.min - 1e-9 && ratio <= limit.max + 1e-9) continue; + out.push({ + side: segment.side ?? "", + ratio, + limitKey, + limit, + kind: ratio < limit.min ? "steep" : "gentle", + startOffsetM: segment.start_offset_m ?? 0, + endOffsetM: segment.end_offset_m ?? 0, + }); + } + return out; +} + +/** 별표2 줄 이름 — 화면·툴팁에 적는 말. */ +export function limitLabel(key: string): string { + if (key === "hard_rock") return "경암"; + if (key === "soft_rock") return "연암"; + return "토사"; +} + +/* ── 기억해 둔 기준 ────────────────────────────────────────────────────────── + * 카드는 그리는 자리마다 컨텍스트를 들고 있지 않다. 표준단면 기본값을 기억해 두는 것과 + * 같은 방식으로(`rememberStandardDefaults`) 여기 한 번 담아 두고 카드가 읽는다. + * ------------------------------------------------------------------------ */ +let remembered: CutSlopeCriteria | null = null; +let rememberedGrade: string | null = null; + +export function rememberCutSlopeCriteria(context: SectionContextResponse | null): void { + remembered = criteriaFrom(context); + rememberedGrade = context?.road_type ?? null; +} + +/** 사용자가 고른 매핑(리핑암·발파암 → 별표2 줄)을 얹는다. 없으면 기본값 그대로. */ +export function applyCutSlopeClassChoice(choice: Record | null | undefined): void { + if (!remembered || !choice) return; + remembered = { ...remembered, classOf: { ...remembered.classOf, ...choice } }; +} + +/** 지금 측점의 위반 목록 — 기준이 없거나 면제 등급이면 빈 목록. */ +export function violationsOf(section: CrossSection): CutSlopeViolation[] { + return cutSlopeViolations(section, remembered, rememberedGrade); +} diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index ff330c5b..14828de6 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -79,6 +79,9 @@ from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, + FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT, + FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES, + FOREST_ROAD_CUT_SLOPE_LIMITS, FOREST_ROAD_MIN_WIDTH_M, NATURAL_SPOIL_MIN_GROUND_SLOPE, SECTION_VERTICAL_EXAGGERATION, @@ -140,6 +143,11 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON ), standard_cross_section=STANDARD_CROSS_SECTION, stored_standard_cross_section=stored_standard, + cut_slope_limits={ + key: list(value) for key, value in FOREST_ROAD_CUT_SLOPE_LIMITS.items() + }, + cut_slope_class_default=dict(FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT), + cut_slope_exempt_grades=list(FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES), rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9b8dac61..c319150e 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -243,6 +243,11 @@ class SectionContextResponse(BaseModel): # **화면은 config 기본값으로, 서버는 저장분으로** 계산해 같은 측점이 갈렸다. # 기존 `standard_cross_section`(기본값)은 그대로 두고 **한 칸만 더한다**. stored_standard_cross_section: dict[str, Any] | None = None + # 절토 비탈 법정 기울기 범위(별표2)와 지반유형 → 별표2 줄 기본 매핑, 검사 제외 등급. + # 브라우저가 상수를 복제하지 않게 **서버가 준 값을 그대로 기억**한다(표준단면과 같은 방식). + cut_slope_limits: dict[str, list[float]] = Field(default_factory=dict) + cut_slope_class_default: dict[str, str] = Field(default_factory=dict) + cut_slope_exempt_grades: list[str] = Field(default_factory=list) # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..f716468a 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -13,6 +13,7 @@ import { buildRockBoundaryControl, sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; +import { limitLabel, violationsOf } from "./B06_Section_Cut_Slope_Check"; import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; import { type DesignChangeHandler, @@ -85,6 +86,26 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } + // 절토 비탈 법정 기울기(별표2) 위반 — 성토사면·미폐합 경고와 같은 자리·같은 모양이다. + // 소단이 서면 실효 경사가 완만해져 위반이 사라진 것처럼 보이므로, 판정은 **구간별** + // 경사(`cut_slope_segments`)로 한다(2026-09-07). + const cutViolations = violationsOf(section); + if (cutViolations.length) { + const worst = cutViolations[0]; + const badge = document.createElement("span"); + badge.className = "b06-cross-card__warning"; + badge.textContent = `⚠ 절토 1:${worst.ratio.toFixed(2)}`; + badge.title = cutViolations + .map( + (item) => + `${item.side === "left" ? "좌" : "우"} 1:${item.ratio.toFixed(2)}` + + ` — ${limitLabel(item.limitKey)} 법정 1:${item.limit.min}~${item.limit.max}` + + ` 범위 밖(${item.kind === "steep" ? "너무 급함" : "너무 완만함"})` + + ` · ${item.startOffsetM.toFixed(2)}~${item.endOffsetM.toFixed(2)}m`, + ) + .join("\n"); + meta.append(badge); + } const structureName = section.structure; if (structureName) { const structure = document.createElement("span"); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 30ba8b6d..4ff7af2e 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -2,6 +2,7 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; +import { rememberCutSlopeCriteria } from "./B06_Section_Cut_Slope_Check"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -647,6 +648,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); + // 절토 비탈 법정 기울기 기준(별표2)도 같은 방식으로 기억해 둔다 — 카드가 읽는다. + rememberCutSlopeCriteria(context); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 9279ab3b..dc670c3d 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -26,6 +26,7 @@ import { } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; +import { applyCutSlopeClassChoice } from "./B06_Section_Cut_Slope_Check"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, @@ -396,8 +397,46 @@ export function createStandardPanel( } actions.append(resetButton); + // ── 절토 법정 기울기 판정 기준 (2026-09-07 사용자 확정) ────────────────── + // 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가른다. 둘을 + // 잇는 문장이 법령·교본에 없어 **프로그램 설정**으로 두고 여기서 고르게 한다. 기본값은 + // 리핑암 → 연암 · 발파암 → 경암(자연스러운 읽기). 절토 경사비가 오는 자리 옆이라 여기 둔다. + const cutClassWrap = document.createElement("div"); + cutClassWrap.className = "b06-std__cutclass"; + const cutClassTitle = document.createElement("p"); + cutClassTitle.className = "b06-std__cutclass-title"; + cutClassTitle.textContent = "절토 법정 기울기 판정 기준 (별표2)"; + cutClassTitle.title = + "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음."; + cutClassWrap.append(cutClassTitle); + const CUT_CLASS_OPTIONS = [ + { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, + { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, + ]; + for (const [kind, label] of [ + ["ripping_rock", "리핑암"], + ["blasting_rock", "발파암"], + ] as const) { + const current = + state.cut_slope_class?.[kind] ?? (kind === "ripping_rock" ? "soft_rock" : "hard_rock"); + const field = createSelectField({ + label, + options: CUT_CLASS_OPTIONS, + value: current, + onChange: (value) => { + state.cut_slope_class = { ...(state.cut_slope_class ?? {}), [kind]: value }; + persist(); + applyCutSlopeClassChoice(state.cut_slope_class); + onApplyAll?.(); + }, + }); + cutClassWrap.append(field.root); + } + // 처음 세울 때도 고른 값을 검사에 반영한다(세션에 남아 있던 선택 포함). + applyCutSlopeClassChoice(state.cut_slope_class); + // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. - root.append(body, loader, actions); + root.append(body, cutClassWrap, loader, actions); return { root, diff --git a/config/config_system_design.py b/config/config_system_design.py index c239ab33..bfb4360d 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -46,6 +46,29 @@ ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} +# ───────────────────────────────────────────────────────────────────────── +# 절토 비탈 법정 기울기 (별표2) +# +# 근거 — 지식DB `resources/knowledge/technical_info/01_임도/02_상세설계/절토_비탈면.md` §1. +# 그 문서의 [구현] 줄대로 **판정은 별표2 범위로** 한다(실무 관행값은 후보일 뿐 기준이 아님). +# 값은 수직 1 에 대한 수평(1:n 의 n). +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_CUT_SLOPE_LIMITS = { + "hard_rock": (0.3, 0.8), # 암석지 — 경암 + "soft_rock": (0.5, 1.2), # 암석지 — 연암 + "soil": (0.8, 1.5), # 토사지역 +} +# 프로그램 지반유형(리핑암·발파암) → 별표2 줄(연암·경암). +# **기본값이며 사용자가 바꿀 수 있다**(2026-09-07 사용자 확정 — 「사용자가 제어할 수 있게」). +# 별표2 는 경암·연암으로 가르고 프로그램은 리핑암·발파암으로 가르는데, 둘을 잇는 문장이 +# 법령·교본에 없다. 그래서 법정 근거가 아니라 **프로그램 설정**으로 두고 화면에서 고르게 한다. +FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT = { + "ripping_rock": "soft_rock", + "blasting_rock": "hard_rock", +} +# 절토 기울기 규정이 없는 등급 — 작업임도(별표2 §1 「작업임도: 절토 기울기 표 규정 없음」). +FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES = ("work",) + # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) -- 2.54.0 From 4e7a4bf7875cf850506ad40b4fe732e58ce9dd40 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:25:46 +0900 Subject: [PATCH 278/441] =?UTF-8?q?fix(=ED=9A=A1=EB=8B=A8):=20=EC=82=AC?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=EA=B0=84=20=EC=9E=AC=EB=A3=8C=EB=A5=BC=20?= =?UTF-8?q?**=EA=B7=B8=EB=A6=B0=20=EA=B2=BD=EC=82=AC=EB=B9=84**=EB=A1=9C?= =?UTF-8?q?=20=EA=B0=80=EB=A6=84=20=E2=80=94=20=E3=80=8C=ED=86=A0=EC=82=AC?= =?UTF-8?q?=20=EA=B2=BD=EC=82=AC=EC=9D=B8=EB=8D=B0=20=EC=95=94=E3=80=8D=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EC=97=86=EC=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 창이 전 측점을 돌려 찾은 것(용화 5601e828 · route 169, 63측점 124구간). `material: "rock"` 인데 경사비가 1.0(토사)인 구간이 **13개** 나왔음. 까닭 — 재료를 **암반 경계선을 다시 재서** 붙였음. 그런데 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 경사로 그린 구간이 경계 아래로 **되돌아가 있는** 일이 흔함. 그것을 경계로 재면 그린 것과 라벨이 어긋남. 고침 — 재료는 **그 구간을 실제로 그린 경사비**를 따라 붙임(암 경사비에 가까우면 암, 토사 경사비에 가까우면 토사). 두 경사비가 같으면(2단 절토 아님) None. 영향 — 별표2 경사 판정이 갈리던 자리임. 13개가 「암인데 너무 완만」 위반으로 잘못 뜰 수 있었음. 토사 구간 50개는 종전대로 위반 0. 자체검증 — 새 시험 1건: 지반이 가파른 암 지반에서 모든 구간의 재료가 그 경사비와 맞는지 확인(어긋나면 실패). 거울 시험도 통과. 전체 542 passed · 18 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Design.py | 14 ++++++++++---- common_util/common_util_cross_design_geometry.ts | 10 ++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 367b2bec..7c7a86a7 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -351,12 +351,18 @@ class _SectionGeometry: berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m if abs(rise - berm_rise) < 1e-9: continue + # 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면 + # 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 + # 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로 + # 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창 + # 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다. material: str | None = None - if self.two_stage: - middle_d = (start_d + end_d) / 2.0 - middle_z = (start_z + end_z) / 2.0 + if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9: + drawn = run / rise material = ( - "soil" if middle_z >= self._rock_boundary_z(side, middle_d) else "rock" + "soil" + if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio) + else "rock" ) segments.append( { diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 87d84ae8..7e7f6d27 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -381,11 +381,13 @@ export class SectionGeometry { const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM; if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부) } + // 재료는 **그 구간을 실제로 그린 경사비**로 가른다(짝 파이썬과 같은 까닭) — + // 경계선을 다시 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다. let material: string | null = null; - if (this.twoStage) { - const middleD = (startD + endD) / 2; - const middleZ = (startZ + endZ) / 2; - material = middleZ >= this.rockBoundaryZ(side, middleD) ? "soil" : "rock"; + if (this.twoStage && Math.abs(this.soilCutRatio - this.cutRatio) > 1e-9) { + const drawn = run / rise; + material = + Math.abs(drawn - this.soilCutRatio) < Math.abs(drawn - this.cutRatio) ? "soil" : "rock"; } segments.push({ side, -- 2.54.0 From db4f17fb1ca683b158f39fbff3b84b40d53d6cfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:38:33 +0900 Subject: [PATCH 279/441] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=84=B1?= =?UTF-8?q?=ED=86=A0=EB=A9=B4=20=EC=86=8C=EB=8B=A8=20+=20=EC=98=B9?= =?UTF-8?q?=EB=B2=BD=20=EC=9D=98=EB=AC=B4=20=ED=8C=90=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=E3=80=8C=EC=86=8C=EB=8B=A8=20=EC=82=AC=EC=9D=B4=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EB=B3=84=20=EC=B5=9C=EB=8C=80=E3=80=8D=EB=A1=9C=20(?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=9C=203-9=20=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 묶음으로 처리했음 — 화면에 5m 판정이 틀린 채 서 있는 시간이 없게. 성토 사면 꼭짓점 짝 신설(`fill_profile_points` · `fillProfilePoints`). 절토와 달리 경사가 하나뿐이라 무릎이 없고, 소단 규칙은 같음. 설계선·지반 교차·꼭짓점 목록이 모두 그 선을 봄. ⚠ **옹벽 의무 판정을 함께 고쳤음** — 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」 (`성토_비탈면.md` §2)를 재는 `fillSlopeLengths` 가 **「성토선은 1:n 직선」을 전제로 수평거리 × 기울기**로 재고 있었음(주석에도 그 전제가 적혀 있었음). 소단이 들어가면 그 전제가 깨져 값이 틀림. 고침 — 소단이 있으면 설계선을 걸어가며 **소단으로 끊긴 한 도막**의 최대 길이를 잼. · 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않음** · 실효 경사로 재면 완만해져 **의무가 사라진 것처럼** 보임 둘 다 틀리므로 「끊긴 한 도막」이 맞는 기준임. **소단이 없는 측점은 종전 식을 그대로 탐** — `design.berm` 이 없으면 예전 계산 그대로라 값이 한 톨도 안 바뀜(구조로 보장). 화면 전후 대조는 다음 단계에서 냄. 자체검증 — 새 시험 2건(성토면에 계단이 서고 같은 거리에서 덜 내려감 · 소단이 없으면 성토선이 곧은 한 줄). 전체 542 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 3 ++ B06_Section/B06_Section_Engine_Design.py | 33 +++++++++++-- B06_Section/B06_Section_UI_Cross_Fit.ts | 49 ++++++++++++++++++- common_util/common_util_cross_berm.py | 46 +++++++++++++++++ common_util/common_util_cross_berm.ts | 45 +++++++++++++++++ .../common_util_cross_design_geometry.ts | 43 +++++++++++++--- 6 files changed, 207 insertions(+), 12 deletions(-) diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 590c76c5..d86d89bd 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -475,6 +475,9 @@ export interface CrossDesign { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음. + * 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; } export interface CrossDesignResponse { diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7c7a86a7..cac4b5b3 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -38,6 +38,7 @@ from B06_Section.B06_Section_Engine_Areas import ( from common_util.common_util_cross_berm import ( BermSpec, cut_profile_points, + fill_profile_points, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from config.config_system import ( @@ -196,6 +197,7 @@ class _SectionGeometry: # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. self.berm = berm self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} + self._fill_points_cache: dict[str, list[tuple[float, float]]] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} @@ -313,6 +315,19 @@ class _SectionGeometry: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.cut_points(side), dist) + def fill_points(self, side: str) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다.""" + if side in self._fill_points_cache: + return self._fill_points_cache[side] + start_dist, start_z = self._slope_start(side) + points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) + self._fill_points_cache[side] = points + return points + + def _fill_slope_z(self, side: str, dist: float) -> float: + """성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.fill_points(side), dist) + def cut_slope_segments(self) -> list[dict[str, Any]]: """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. @@ -445,7 +460,7 @@ class _SectionGeometry: max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist - fill_line = start_z - (dist - start_dist) / self.fill_ratio + fill_line = self._fill_slope_z(side, dist) if fill_line - self._ground_at(signed) <= 0: result = dist break @@ -477,9 +492,7 @@ class _SectionGeometry: return z0 + (z1 - z0) * ratio return points[-1][1] role = self.left_role if side == "left" else self.right_role - start_dist, start_z = self._slope_start(side) dist = abs(offset_m) - run = dist - start_dist if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) @@ -490,8 +503,7 @@ class _SectionGeometry: cross = self.fill_cross_dist(side) if cross is not None and dist >= cross: return ground_m - fill_line = start_z - run / self.fill_ratio - return max(fill_line, ground_m) + return max(self._fill_slope_z(side, dist), ground_m) def breakpoints(self) -> list[float]: """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" @@ -508,6 +520,17 @@ class _SectionGeometry: if cross is not None and offset > cross + 1e-9: break # 지반과 만난 뒤는 절토가 없다 points.append(offset if side == "left" else -offset) + # 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if self.berm is not None: + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "fill": + continue + cross = self.fill_cross_dist(side) + for offset, _z in self.fill_points(side): + if cross is not None and offset > cross + 1e-9: + break + points.append(offset if side == "left" else -offset) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index ac6cd84a..af03913e 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -103,13 +103,60 @@ export function fillSlopeLengths(section: CrossSection): { const meet = meetOffset(designAt, groundAt, start, limit, outward); const end = Math.abs(meet) > Math.abs(limit) ? limit : meet; lengths[side] = { - lengthM: Math.abs(end - start) * slant, + // 소단이 있으면 사면이 계단으로 끊기므로 **구간별 최대**를 잰다. 없으면 종전대로 + // 수평 성분 × 기울기 — 값이 한 톨도 안 바뀐다(2026-09-07, 계획서 3-9). + lengthM: design.berm + ? longestFillRun(design, start, end, slant) + : Math.abs(end - start) * slant, open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M, }; } return lengths; } +/** + * 소단으로 끊긴 성토사면에서 **가장 긴 한 구간**의 경사길이(m). + * + * 왜 최대인가 — 법정 기준은 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」다 + * (`성토_비탈면.md` §2). 소단은 사면을 끊는 시설이므로, 판정은 **끊긴 한 도막**을 봐야 한다. + * 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않고**, 반대로 실효 경사로 + * 재면 완만해져 **의무가 사라진 것처럼** 보인다. 둘 다 틀린다. + * + * 평탄부(소단)는 길이에 안 넣고 그 자리에서 도막을 끊는다. + */ +function longestFillRun( + design: CrossDesign, + startOffset: number, + endOffset: number, + slant: number, +): number { + const berm = design.berm; + if (!berm) return Math.abs(endOffset - startOffset) * slant; + const low = Math.min(startOffset, endOffset); + const high = Math.max(startOffset, endOffset); + const bermRise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + let longest = 0; + let current = 0; + const line = design.design_line; + for (let index = 1; index < line.length; index += 1) { + const a = line[index - 1]; + const b = line[index]; + const from = Math.max(Math.min(a.offset_m, b.offset_m), low); + const to = Math.min(Math.max(a.offset_m, b.offset_m), high); + if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다 + const run = Math.abs(b.offset_m - a.offset_m); + const rise = b.elevation_m - a.elevation_m; + const isBerm = Math.abs(run - berm.width_m) < 1e-6 && Math.abs(rise - bermRise) < 1e-6; + if (isBerm) { + longest = Math.max(longest, current); + current = 0; + continue; + } + current += (to - from) * slant; + } + return Math.max(longest, current); +} + /** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ function protectedSpan( design: CrossDesign, diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 02c69c73..ae79cb0b 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -132,6 +132,52 @@ def cut_profile_points( return _dedupe(points) +def fill_profile_points( + start_dist: float, + start_z: float, + fill_ratio: float, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, +) -> list[tuple[float, float]]: + """성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**. + + 절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 — + 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는 + 안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로). + """ + points: list[tuple[float, float]] = [(start_dist, start_z)] + dist, elevation = start_dist, start_z + slant_since_berm = 0.0 + limit = start_dist + max_reach_m + berm_rise = ( + math.tan(math.radians(berm.slope_deg)) * berm.width_m + if berm is not None and berm.width_m > 0 + else 0.0 + ) + ratio = max(fill_ratio, 1e-6) + + while dist < limit: + drop = _STEP_M / ratio + slant = math.hypot(_STEP_M, drop) + if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: + remain = max(berm.interval_m - slant_since_berm, 0.0) + run = remain / math.hypot(1.0, 1.0 / ratio) + dist += run + elevation -= run / ratio + points.append((dist, elevation)) # 소단 안쪽 모서리 + dist += berm.width_m + elevation += berm_rise + points.append((dist, elevation)) # 소단 바깥 모서리 + slant_since_berm = 0.0 + continue + dist += _STEP_M + elevation -= drop + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]: """같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게.""" out: list[tuple[float, float]] = [] diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index c4e0a192..5d97e807 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -122,6 +122,51 @@ export function cutProfilePoints( return dedupe(points); } +/** + * 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**. + * + * 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다. + */ +export function fillProfilePoints( + startDist: number, + startZ: number, + fillRatio: number, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, +): Array<[number, number]> { + const points: Array<[number, number]> = [[startDist, startZ]]; + let dist = startDist; + let elevation = startZ; + let slantSinceBerm = 0; + const limit = startDist + maxReachM; + const bermRise = + berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; + const ratio = Math.max(fillRatio, 1e-6); + + while (dist < limit) { + const drop = STEP_M / ratio; + const slant = Math.hypot(STEP_M, drop); + if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { + const remain = Math.max(berm.intervalM - slantSinceBerm, 0); + const run = remain / Math.hypot(1, 1 / ratio); + dist += run; + elevation -= run / ratio; + points.push([dist, elevation]); // 소단 안쪽 모서리 + dist += berm.widthM; + elevation += bermRise; + points.push([dist, elevation]); // 소단 바깥 모서리 + slantSinceBerm = 0; + continue; + } + dist += STEP_M; + elevation -= drop; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + /** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ function dedupe(points: Array<[number, number]>): Array<[number, number]> { const out: Array<[number, number]> = []; diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 7e7f6d27..99f74aa4 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,7 +6,12 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ -import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; +import { + type BermSpec, + cutProfilePoints, + elevationAt, + fillProfilePoints, +} from "./common_util_cross_berm"; /** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ export interface CutSlopeSegment { @@ -107,6 +112,7 @@ export class SectionGeometry { /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ berm: BermSpec | null = null; private cutPointsCache = new Map>(); + private fillPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -251,6 +257,21 @@ export class SectionGeometry { return elevationAt(this.cutPoints(side), dist); } + /** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */ + fillPoints(side: string): Array<[number, number]> { + const cached = this.fillPointsCache.get(side); + if (cached !== undefined) return cached; + const [startDist, startZ] = this.slopeStart(side); + const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm); + this.fillPointsCache.set(side, points); + return points; + } + + /** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */ + private fillSlopeZ(side: string, dist: number): number { + return elevationAt(this.fillPoints(side), dist); + } + /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ cutCrossDist(side: string): number | null { const cached = this.cutCross.get(side); @@ -279,12 +300,12 @@ export class SectionGeometry { if (cached !== undefined) return cached; let result: number | null = null; if (this.groundAt !== null) { - const [startDist, startZ] = this.slopeStart(side); + const [startDist] = this.slopeStart(side); let dist = startDist; const maxDist = startDist + CROSS_MAX_M; while (dist <= maxDist) { const signed = side === "left" ? dist : -dist; - const fillLine = startZ - (dist - startDist) / this.fillRatio; + const fillLine = this.fillSlopeZ(side, dist); if (fillLine - this.groundAt(signed) <= 0) { result = dist; break; @@ -339,9 +360,7 @@ export class SectionGeometry { } } const role = side === "left" ? this.leftRole : this.rightRole; - const [startDist, startZ] = this.slopeStart(side); const dist = Math.abs(offsetM); - const run = dist - startDist; if (role === "cut") { const cross = this.cutCrossDist(side); if (cross !== null && dist >= cross) return groundM; @@ -349,7 +368,7 @@ export class SectionGeometry { } const cross = this.fillCrossDist(side); if (cross !== null && dist >= cross) return groundM; - return Math.max(startZ - run / this.fillRatio, groundM); + return Math.max(this.fillSlopeZ(side, dist), groundM); } /** @@ -419,6 +438,18 @@ export class SectionGeometry { } } } + // 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. + if (this.berm !== null) { + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "fill") continue; + const cross = this.fillCrossDist(side); + for (const [offset] of this.fillPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; + points.push(side === "left" ? offset : -offset); + } + } + } for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side); -- 2.54.0 From 1e596846cd0808719eb378e0beb3ae2d07a83706 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:42:32 +0900 Subject: [PATCH 280/441] =?UTF-8?q?feat(B06):=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=ED=8C=90=EC=A0=95=20=EC=95=94=EC=A7=88?= =?UTF-8?q?=EC=9D=84=20=EC=84=A4=EA=B3=84=EC=9E=90=EA=B0=80=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B2=8C=20=E2=80=94=20=EA=B8=B0=EB=B3=B8=20=EA=B2=BD?= =?UTF-8?q?=EC=95=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07): 「연암과 경암도 별도의 버튼으로 선택하게 하고 경암을 기본값으로 선택. 리핑을 할지 발파를 할지는 설계자가 선택 필요함.」 **축이 둘이었음 — 섞지 말 것.** · **암질**(연암·경암) = 별표2 경사 판정의 기준. 설계자가 고름. 기본 **경암**. · **굴착 공법**(리핑암·발파암, `cut_rock_kind`) = 어떻게 파는가. 수량·단가 몫. 처음에는 공법에서 암질을 유추하려 했는데 **그것이 잘못 세운 문제**였음. 매핑을 걷어내고 암질 선택 하나로 바꿈 — 표준 횡단면 설정 안, 자리는 그대로. - config 매핑 상수 → `FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT = "hard_rock"`. - 판정에서 `cut_rock_kind` 를 뗌. 수량 쪽 쓰임은 그대로 둠. - 실측(용화 63측점·구간 124개) — 기본값 경암에서 **위반 0건**. config 기본 절토비 1:0.4 가 경암 범위(0.3~0.8) 안임. 토사 63구간도 0건. **기존 설계·수량 안 바뀜.** (연암으로 고르면 61건 — 1:0.4 가 연암 하한 0.5 밖이라 그때는 설계 검토가 필요함.) 시험 8건 — 별표2 값 · 작업임도 제외 · **암질은 설계자가 고르고 기본은 경암** · **공법으로 암질을 추론하지 않음** · 구간별 판정 · 소단이 있어도 위반이 안 사라짐 · 경계값 통과 · 카드에 같은 방식으로 붙는지. 7-3 회귀에 소단 한 줄 더함 — 「소단은 값이 아니라 **기하 입력**이라 계산 뒤에 베껴 붙이면 `berm` 값만 남고 계단이 안 그려진다」. 저장분 소단을 **계산 전에** 읽어 넣는 순서를 지킴. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 15 +++-- B06_Section/B06_Section_Cut_Slope_Check.ts | 22 +++--- B06_Section/B06_Section_Router.py | 4 +- B06_Section/B06_Section_Schema.py | 2 +- B06_Section/B06_Section_UI_Standard_Panel.ts | 70 +++++++++----------- config/config_system_design.py | 16 ++--- 6 files changed, 62 insertions(+), 67 deletions(-) diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 590c76c5..c2493a9a 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -46,12 +46,13 @@ export interface StandardCrossGroup { export type StandardCrossKey = "soil" | "rock" | "paved"; export type StandardCrossSection = Record & { - /** 절토 법정 기울기 판정에 쓸 별표2 줄 — 지반유형(리핑암·발파암) → `soft_rock`/`hard_rock`. + /** 절토 경사 판정에 쓸 **암질** — `hard_rock`(경암) / `soft_rock`(연암). 기본 경암. * - * 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가르는데 둘을 잇는 - * 문장이 법령·교본에 없다. 그래서 법정 근거가 아니라 **사용자가 고르는 설정**으로 두고 - * 표준단면 설정과 함께 저장한다(2026-09-07 사용자 확정). 없으면 기본값을 쓴다. */ - cut_slope_class?: Record; + * ⚠ 굴착 공법(리핑암·발파암, `cut_rock_kind`)과 **다른 축**이다. 공법은 수량·단가 몫이고 + * 암질은 별표2 경사 판정의 기준이며, 한쪽에서 다른 쪽을 추론하지 않는다 + * (2026-09-07 사용자 확정 — 「리핑을 할지 발파를 할지는 설계자가 선택」). + * 표준단면 설정과 함께 저장된다. 없으면 기본값(경암). */ + rock_quality?: string; }; // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 @@ -87,8 +88,8 @@ export interface SectionContextResponse { /** 절토 비탈 법정 기울기 범위(별표2) — `hard_rock`·`soft_rock`·`soil` → [최소, 최대]. * 상수를 화면에 복제하지 않으려고 **서버가 준 값을 그대로** 쓴다(2026-09-07). */ cut_slope_limits?: Record; - /** 지반유형(리핑암·발파암) → 별표2 줄. **사용자가 바꿀 수 있는 설정값**의 기본값이다. */ - cut_slope_class_default?: Record; + /** 절토 경사 판정에 쓸 **암질** 기본값(`hard_rock`/`soft_rock`). 설계자가 고른다. */ + cut_slope_rock_quality_default?: string; /** 절토 기울기 규정이 없는 등급(작업임도). */ cut_slope_exempt_grades?: string[]; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ diff --git a/B06_Section/B06_Section_Cut_Slope_Check.ts b/B06_Section/B06_Section_Cut_Slope_Check.ts index ba64fa88..a3371c42 100644 --- a/B06_Section/B06_Section_Cut_Slope_Check.ts +++ b/B06_Section/B06_Section_Cut_Slope_Check.ts @@ -24,8 +24,9 @@ export interface CutSlopeLimit { export interface CutSlopeCriteria { /** 별표2 범위표 — `hard_rock`·`soft_rock`·`soil`. */ limits: Record; - /** 지반유형(리핑암·발파암) → 별표2 줄. 사용자가 바꿀 수 있는 **설정값**이다. */ - classOf: Record; + /** 절토 경사 판정에 쓸 **암질** — `hard_rock`(경암) / `soft_rock`(연암). 설계자가 고른다. + * ⚠ 굴착 공법(리핑암·발파암)과 다른 축이라 공법에서 추론하지 않는다(2026-09-07 사용자 확정). */ + rockQuality: string; /** 절토 기울기 규정이 없는 등급(작업임도). */ exemptGrades: ReadonlyArray; } @@ -52,7 +53,7 @@ export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCr } return { limits: table, - classOf: context?.cut_slope_class_default ?? {}, + rockQuality: context?.cut_slope_rock_quality_default ?? "hard_rock", exemptGrades: context?.cut_slope_exempt_grades ?? [], }; } @@ -62,7 +63,7 @@ export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCr * * · 등급이 면제(작업임도)면 빈 목록. * · 구간이 없으면(성토 측점 등) 빈 목록. - * · 암 구간은 측점의 `cut_rock_kind` 로 별표2 줄을 고른다 — 구간에는 `rock`/`soil` 만 있다. + * · 암 구간은 **설계자가 고른 암질**로 판정한다 — 굴착 공법(`cut_rock_kind`)은 보지 않는다. */ export function cutSlopeViolations( section: CrossSection, @@ -71,12 +72,11 @@ export function cutSlopeViolations( ): CutSlopeViolation[] { if (!criteria) return []; if (gradeClass && criteria.exemptGrades.includes(gradeClass)) return []; - const design = section.design as - { cut_slope_segments?: unknown; cut_rock_kind?: string | null } | undefined; + const design = section.design as { cut_slope_segments?: unknown } | undefined; const segments = design?.cut_slope_segments; if (!Array.isArray(segments) || !segments.length) return []; - const rockKey = criteria.classOf[design?.cut_rock_kind ?? ""] ?? "hard_rock"; + const rockKey = criteria.rockQuality || "hard_rock"; const out: CutSlopeViolation[] = []; for (const raw of segments) { const segment = raw as { @@ -125,10 +125,10 @@ export function rememberCutSlopeCriteria(context: SectionContextResponse | null) rememberedGrade = context?.road_type ?? null; } -/** 사용자가 고른 매핑(리핑암·발파암 → 별표2 줄)을 얹는다. 없으면 기본값 그대로. */ -export function applyCutSlopeClassChoice(choice: Record | null | undefined): void { - if (!remembered || !choice) return; - remembered = { ...remembered, classOf: { ...remembered.classOf, ...choice } }; +/** 설계자가 고른 암질을 얹는다. 없으면 기본값(경암) 그대로. */ +export function applyRockQualityChoice(quality: string | null | undefined): void { + if (!remembered || !quality) return; + remembered = { ...remembered, rockQuality: quality }; } /** 지금 측점의 위반 목록 — 기준이 없거나 면제 등급이면 빈 목록. */ diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 60fb3384..57cb8470 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -80,9 +80,9 @@ from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, - FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT, FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES, FOREST_ROAD_CUT_SLOPE_LIMITS, + FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT, FOREST_ROAD_MIN_WIDTH_M, NATURAL_SPOIL_MIN_GROUND_SLOPE, SECTION_VERTICAL_EXAGGERATION, @@ -147,7 +147,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON cut_slope_limits={ key: list(value) for key, value in FOREST_ROAD_CUT_SLOPE_LIMITS.items() }, - cut_slope_class_default=dict(FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT), + cut_slope_rock_quality_default=FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT, cut_slope_exempt_grades=list(FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES), rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index c319150e..427fb1c3 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -246,7 +246,7 @@ class SectionContextResponse(BaseModel): # 절토 비탈 법정 기울기 범위(별표2)와 지반유형 → 별표2 줄 기본 매핑, 검사 제외 등급. # 브라우저가 상수를 복제하지 않게 **서버가 준 값을 그대로 기억**한다(표준단면과 같은 방식). cut_slope_limits: dict[str, list[float]] = Field(default_factory=dict) - cut_slope_class_default: dict[str, str] = Field(default_factory=dict) + cut_slope_rock_quality_default: str = "hard_rock" cut_slope_exempt_grades: list[str] = Field(default_factory=list) # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index dc670c3d..f9a81493 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -26,7 +26,7 @@ import { } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; -import { applyCutSlopeClassChoice } from "./B06_Section_Cut_Slope_Check"; +import { applyRockQualityChoice } from "./B06_Section_Cut_Slope_Check"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, @@ -397,46 +397,40 @@ export function createStandardPanel( } actions.append(resetButton); - // ── 절토 법정 기울기 판정 기준 (2026-09-07 사용자 확정) ────────────────── - // 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가른다. 둘을 - // 잇는 문장이 법령·교본에 없어 **프로그램 설정**으로 두고 여기서 고르게 한다. 기본값은 - // 리핑암 → 연암 · 발파암 → 경암(자연스러운 읽기). 절토 경사비가 오는 자리 옆이라 여기 둔다. - const cutClassWrap = document.createElement("div"); - cutClassWrap.className = "b06-std__cutclass"; - const cutClassTitle = document.createElement("p"); - cutClassTitle.className = "b06-std__cutclass-title"; - cutClassTitle.textContent = "절토 법정 기울기 판정 기준 (별표2)"; - cutClassTitle.title = - "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음."; - cutClassWrap.append(cutClassTitle); - const CUT_CLASS_OPTIONS = [ - { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, - { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, - ]; - for (const [kind, label] of [ - ["ripping_rock", "리핑암"], - ["blasting_rock", "발파암"], - ] as const) { - const current = - state.cut_slope_class?.[kind] ?? (kind === "ripping_rock" ? "soft_rock" : "hard_rock"); - const field = createSelectField({ - label, - options: CUT_CLASS_OPTIONS, - value: current, - onChange: (value) => { - state.cut_slope_class = { ...(state.cut_slope_class ?? {}), [kind]: value }; - persist(); - applyCutSlopeClassChoice(state.cut_slope_class); - onApplyAll?.(); - }, - }); - cutClassWrap.append(field.root); - } + // ── 절토 법정 기울기 판정 기준: **암질** (2026-09-07 사용자 확정) ──────────── + // 「연암과 경암도 별도의 버튼으로 선택하게 하고 경암을 기본값으로. 리핑을 할지 발파를 + // 할지는 설계자가 선택 필요함」 — 축이 둘이다. **암질**(연암·경암)은 별표2 경사 판정의 + // 기준이고, **굴착 공법**(리핑암·발파암)은 수량·단가 몫이다. 한쪽에서 다른 쪽을 추론하지 + // 않는다. 절토 경사비가 오는 자리 옆이라 여기 둔다. + const rockQualityWrap = document.createElement("div"); + rockQualityWrap.className = "b06-std__cutclass"; + const rockQualityTitle = document.createElement("p"); + rockQualityTitle.className = "b06-std__cutclass-title"; + rockQualityTitle.textContent = "절토 법정 기울기 판정 암질 (별표2)"; + rockQualityTitle.title = + "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음.\n" + + "굴착 공법(리핑·발파)과는 다른 값이다 — 공법은 수량·단가에서 따로 고른다."; + rockQualityWrap.append(rockQualityTitle); + const rockQualityField = createSelectField({ + label: "암질", + options: [ + { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, + { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, + ], + value: state.rock_quality ?? "hard_rock", + onChange: (value) => { + state.rock_quality = value; + persist(); + applyRockQualityChoice(value); + onApplyAll?.(); + }, + }); + rockQualityWrap.append(rockQualityField.root); // 처음 세울 때도 고른 값을 검사에 반영한다(세션에 남아 있던 선택 포함). - applyCutSlopeClassChoice(state.cut_slope_class); + applyRockQualityChoice(state.rock_quality ?? "hard_rock"); // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. - root.append(body, cutClassWrap, loader, actions); + root.append(body, rockQualityWrap, loader, actions); return { root, diff --git a/config/config_system_design.py b/config/config_system_design.py index bfb4360d..d430961c 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -58,14 +58,14 @@ FOREST_ROAD_CUT_SLOPE_LIMITS = { "soft_rock": (0.5, 1.2), # 암석지 — 연암 "soil": (0.8, 1.5), # 토사지역 } -# 프로그램 지반유형(리핑암·발파암) → 별표2 줄(연암·경암). -# **기본값이며 사용자가 바꿀 수 있다**(2026-09-07 사용자 확정 — 「사용자가 제어할 수 있게」). -# 별표2 는 경암·연암으로 가르고 프로그램은 리핑암·발파암으로 가르는데, 둘을 잇는 문장이 -# 법령·교본에 없다. 그래서 법정 근거가 아니라 **프로그램 설정**으로 두고 화면에서 고르게 한다. -FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT = { - "ripping_rock": "soft_rock", - "blasting_rock": "hard_rock", -} +# 절토 경사 판정에 쓸 **암질** 기본값 (2026-09-07 사용자 확정). +# +# ⚠ 축이 둘이다 — 섞지 말 것. +# · **암질**(연암·경암) = 별표2 경사 판정의 기준. 설계자가 고른다. 기본값 **경암**. +# · **굴착 공법**(리핑암·발파암, `cut_rock_kind`) = 어떻게 파는가. 수량·단가 몫이다. +# 한쪽에서 다른 쪽을 **추론하지 않는다**(사용자: 「리핑을 할지 발파를 할지는 설계자가 선택」). +# 처음에는 공법에서 암질을 유추하려 했으나 그것이 잘못 세운 문제였다. +FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT = "hard_rock" # 절토 기울기 규정이 없는 등급 — 작업임도(별표2 §1 「작업임도: 절토 기울기 표 규정 없음」). FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES = ("work",) -- 2.54.0 From 36f46a0d940ecc86c9e37ad2f187f921b5176b01 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:43:08 +0900 Subject: [PATCH 281/441] auto: 2026-09-07 16:43 (EOMSANGDON-HOME) --- .gitattributes | 3 +++ .gitignore | 2 ++ package.json | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c768e8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# 동봉한 LibreDWG 실행 파일 — 줄바꿈 변환이 닿으면 실행이 깨진다. +B07_DesignDetail/openwebcad/tools/libredwg/*.exe binary +B07_DesignDetail/openwebcad/tools/libredwg/*.dll binary diff --git a/.gitignore b/.gitignore index 5e816280..e1a96c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ tmp/ # 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04). # 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py). config/corridor_node/ +# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06). +config/server_calc_node/ diff --git a/package.json b/package.json index a5e66137..7e20859b 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", + "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", -- 2.54.0 From cc428d7e12fc05387397611c4e33c6dfdf011024 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:56:26 +0900 Subject: [PATCH 282/441] =?UTF-8?q?revert(B06):=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=EB=B2=95=EC=A0=95=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=ED=8F=90=EA=B8=B0=20=E2=80=94=20=EC=95=94=EC=A7=88=EC=9D=84=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=8F=84=EC=97=90=EC=84=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07): 「연암과 경암 / 발파암과 리핑암 선택은 횡단도에서 선택 안함. 사유는 향후 설계내역에서 설계자가 직접 비율로 지정하기로 함. 암반 지정과 범위 애매모호한 경우가 있어. 실무자는 그렇게 하기로 판단함. 대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영.」 **왜 폐기인가** — 판정이 서려면 측점마다 암질(연암·경암)을 못 박아야 하는데, 실무에서 암반 지정·범위가 애매해 그 못 박음 자체가 틀린 전제였음. 암 비율은 **설계내역 단계에서 설계자가 비율로** 넣음. 기준이 없으니 경고도 없음. 되돌린 것 — 1e596846(암질 선택) + 973128f7(별표2 경고). 카드 경고 배지 · 판정 모듈 · config 범위표 · context 필드 · 표준 패널 암질 칸 전부 사라짐. ⚠ **남긴 것** — `cut_slope_segments`(구간별 경사)는 지우지 않음. 소단 기하가 그 위에 서 있고 구간 경사를 아는 값이라 뒤에 쓸 자리가 있음. 4e7a4bf7(사면 구간 재료를 그린 경사비로 가름 — 무릎 위 라벨 13구간 오류 수정)도 남김. 검사와 무관하게 옳은 고침임. 시험 4건 — 되살아나지 않게 막는 쪽으로 다시 씀(판정 모듈 없음 · 카드 경고 없음 · 암질 고르는 자리 없음 · **구간별 경사값은 남아 있음**). Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 17 +-- B06_Section/B06_Section_Cut_Slope_Check.ts | 137 ------------------ B06_Section/B06_Section_Router.py | 8 - B06_Section/B06_Section_Schema.py | 5 - .../B06_Section_UI_Cross_Card_Chrome.ts | 21 --- B06_Section/B06_Section_UI_Page.ts | 3 - B06_Section/B06_Section_UI_Standard_Panel.ts | 35 +---- config/config_system_design.py | 23 --- 8 files changed, 2 insertions(+), 247 deletions(-) delete mode 100644 B06_Section/B06_Section_Cut_Slope_Check.ts diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index c2493a9a..42dacd1b 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -45,15 +45,7 @@ export interface StandardCrossGroup { /** 표준 횡단면 설정 패널 그룹 키. */ export type StandardCrossKey = "soil" | "rock" | "paved"; -export type StandardCrossSection = Record & { - /** 절토 경사 판정에 쓸 **암질** — `hard_rock`(경암) / `soft_rock`(연암). 기본 경암. - * - * ⚠ 굴착 공법(리핑암·발파암, `cut_rock_kind`)과 **다른 축**이다. 공법은 수량·단가 몫이고 - * 암질은 별표2 경사 판정의 기준이며, 한쪽에서 다른 쪽을 추론하지 않는다 - * (2026-09-07 사용자 확정 — 「리핑을 할지 발파를 할지는 설계자가 선택」). - * 표준단면 설정과 함께 저장된다. 없으면 기본값(경암). */ - rock_quality?: string; -}; +export type StandardCrossSection = Record; // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 // `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지). @@ -85,13 +77,6 @@ export interface SectionContextResponse { /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ stored_standard_cross_section?: StandardCrossSection | null; - /** 절토 비탈 법정 기울기 범위(별표2) — `hard_rock`·`soft_rock`·`soil` → [최소, 최대]. - * 상수를 화면에 복제하지 않으려고 **서버가 준 값을 그대로** 쓴다(2026-09-07). */ - cut_slope_limits?: Record; - /** 절토 경사 판정에 쓸 **암질** 기본값(`hard_rock`/`soft_rock`). 설계자가 고른다. */ - cut_slope_rock_quality_default?: string; - /** 절토 기울기 규정이 없는 등급(작업임도). */ - cut_slope_exempt_grades?: string[]; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; diff --git a/B06_Section/B06_Section_Cut_Slope_Check.ts b/B06_Section/B06_Section_Cut_Slope_Check.ts deleted file mode 100644 index a3371c42..00000000 --- a/B06_Section/B06_Section_Cut_Slope_Check.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* ============================================================================= - * B06_Section_Cut_Slope_Check.ts - * 절토 비탈 **법정 기울기 검사**(별표2) — 순수 판정만 둔다(그리기는 카드 몫). - * - * 왜 필요한가 — 평면 곡선반경도, 성토사면 길이(5m·기슭막이 의무)도 위반 표시가 있는데 - * **절토 경사만 검사가 없었다**(2026-09-07). 절토 경사비는 표준 횡단면 설정에서 오는 - * 입력값일 뿐이라 별표2 범위를 벗어나도 아무 표시가 없었다. - * - * ⚠ **실효 경사로 보면 안 된다.** 소단이 서면 사면 전체를 하나로 잰 경사가 완만해져 - * **위반이 사라진 것처럼** 보인다(실측: 폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). - * 그래서 `design.cut_slope_segments`(소단을 뺀 **구간별** 경사)를 읽는다. - * - * 기준값·매핑은 **서버가 준 값**을 쓴다(config 상수를 화면에 복제하지 않는다). - * ========================================================================== */ - -import type { CrossSection, SectionContextResponse } from "./B06_Section_Api_Fetch"; - -/** 별표2 한 줄 — 수직 1 에 대한 수평(1:n 의 n) 범위. */ -export interface CutSlopeLimit { - min: number; - max: number; -} - -export interface CutSlopeCriteria { - /** 별표2 범위표 — `hard_rock`·`soft_rock`·`soil`. */ - limits: Record; - /** 절토 경사 판정에 쓸 **암질** — `hard_rock`(경암) / `soft_rock`(연암). 설계자가 고른다. - * ⚠ 굴착 공법(리핑암·발파암)과 다른 축이라 공법에서 추론하지 않는다(2026-09-07 사용자 확정). */ - rockQuality: string; - /** 절토 기울기 규정이 없는 등급(작업임도). */ - exemptGrades: ReadonlyArray; -} - -export interface CutSlopeViolation { - side: string; - ratio: number; - /** 어느 별표2 줄로 판정했나 — 툴팁에 적는다. */ - limitKey: string; - limit: CutSlopeLimit; - /** 급한 쪽 위반인지(`steep`) 완만한 쪽 위반인지(`gentle`). */ - kind: "steep" | "gentle"; - startOffsetM: number; - endOffsetM: number; -} - -/** 서버 컨텍스트에서 기준을 꺼낸다. 값이 안 왔으면 null — 검사를 하지 않는다. */ -export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCriteria | null { - const limits = context?.cut_slope_limits; - if (!limits || !Object.keys(limits).length) return null; - const table: Record = {}; - for (const [key, pair] of Object.entries(limits)) { - if (Array.isArray(pair) && pair.length >= 2) table[key] = { min: pair[0], max: pair[1] }; - } - return { - limits: table, - rockQuality: context?.cut_slope_rock_quality_default ?? "hard_rock", - exemptGrades: context?.cut_slope_exempt_grades ?? [], - }; -} - -/** - * 이 측점의 절토 구간이 별표2 범위를 벗어났는지. - * - * · 등급이 면제(작업임도)면 빈 목록. - * · 구간이 없으면(성토 측점 등) 빈 목록. - * · 암 구간은 **설계자가 고른 암질**로 판정한다 — 굴착 공법(`cut_rock_kind`)은 보지 않는다. - */ -export function cutSlopeViolations( - section: CrossSection, - criteria: CutSlopeCriteria | null, - gradeClass: string | null, -): CutSlopeViolation[] { - if (!criteria) return []; - if (gradeClass && criteria.exemptGrades.includes(gradeClass)) return []; - const design = section.design as { cut_slope_segments?: unknown } | undefined; - const segments = design?.cut_slope_segments; - if (!Array.isArray(segments) || !segments.length) return []; - - const rockKey = criteria.rockQuality || "hard_rock"; - const out: CutSlopeViolation[] = []; - for (const raw of segments) { - const segment = raw as { - side?: string; - ratio?: number; - material?: string | null; - start_offset_m?: number; - end_offset_m?: number; - }; - const ratio = segment.ratio; - if (typeof ratio !== "number" || !Number.isFinite(ratio)) continue; - const limitKey = segment.material === "rock" ? rockKey : "soil"; - const limit = criteria.limits[limitKey]; - if (!limit) continue; - // 경계값은 통과 — 1:0.8 은 「0.3~0.8」 안이다. - if (ratio >= limit.min - 1e-9 && ratio <= limit.max + 1e-9) continue; - out.push({ - side: segment.side ?? "", - ratio, - limitKey, - limit, - kind: ratio < limit.min ? "steep" : "gentle", - startOffsetM: segment.start_offset_m ?? 0, - endOffsetM: segment.end_offset_m ?? 0, - }); - } - return out; -} - -/** 별표2 줄 이름 — 화면·툴팁에 적는 말. */ -export function limitLabel(key: string): string { - if (key === "hard_rock") return "경암"; - if (key === "soft_rock") return "연암"; - return "토사"; -} - -/* ── 기억해 둔 기준 ────────────────────────────────────────────────────────── - * 카드는 그리는 자리마다 컨텍스트를 들고 있지 않다. 표준단면 기본값을 기억해 두는 것과 - * 같은 방식으로(`rememberStandardDefaults`) 여기 한 번 담아 두고 카드가 읽는다. - * ------------------------------------------------------------------------ */ -let remembered: CutSlopeCriteria | null = null; -let rememberedGrade: string | null = null; - -export function rememberCutSlopeCriteria(context: SectionContextResponse | null): void { - remembered = criteriaFrom(context); - rememberedGrade = context?.road_type ?? null; -} - -/** 설계자가 고른 암질을 얹는다. 없으면 기본값(경암) 그대로. */ -export function applyRockQualityChoice(quality: string | null | undefined): void { - if (!remembered || !quality) return; - remembered = { ...remembered, rockQuality: quality }; -} - -/** 지금 측점의 위반 목록 — 기준이 없거나 면제 등급이면 빈 목록. */ -export function violationsOf(section: CrossSection): CutSlopeViolation[] { - return cutSlopeViolations(section, remembered, rememberedGrade); -} diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 57cb8470..006ad692 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -80,9 +80,6 @@ from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, - FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES, - FOREST_ROAD_CUT_SLOPE_LIMITS, - FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT, FOREST_ROAD_MIN_WIDTH_M, NATURAL_SPOIL_MIN_GROUND_SLOPE, SECTION_VERTICAL_EXAGGERATION, @@ -144,11 +141,6 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON ), standard_cross_section=STANDARD_CROSS_SECTION, stored_standard_cross_section=stored_standard, - cut_slope_limits={ - key: list(value) for key, value in FOREST_ROAD_CUT_SLOPE_LIMITS.items() - }, - cut_slope_rock_quality_default=FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT, - cut_slope_exempt_grades=list(FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES), rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 427fb1c3..9b8dac61 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -243,11 +243,6 @@ class SectionContextResponse(BaseModel): # **화면은 config 기본값으로, 서버는 저장분으로** 계산해 같은 측점이 갈렸다. # 기존 `standard_cross_section`(기본값)은 그대로 두고 **한 칸만 더한다**. stored_standard_cross_section: dict[str, Any] | None = None - # 절토 비탈 법정 기울기 범위(별표2)와 지반유형 → 별표2 줄 기본 매핑, 검사 제외 등급. - # 브라우저가 상수를 복제하지 않게 **서버가 준 값을 그대로 기억**한다(표준단면과 같은 방식). - cut_slope_limits: dict[str, list[float]] = Field(default_factory=dict) - cut_slope_rock_quality_default: str = "hard_rock" - cut_slope_exempt_grades: list[str] = Field(default_factory=list) # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index f716468a..d7cd8c5c 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -13,7 +13,6 @@ import { buildRockBoundaryControl, sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; -import { limitLabel, violationsOf } from "./B06_Section_Cut_Slope_Check"; import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; import { type DesignChangeHandler, @@ -86,26 +85,6 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } - // 절토 비탈 법정 기울기(별표2) 위반 — 성토사면·미폐합 경고와 같은 자리·같은 모양이다. - // 소단이 서면 실효 경사가 완만해져 위반이 사라진 것처럼 보이므로, 판정은 **구간별** - // 경사(`cut_slope_segments`)로 한다(2026-09-07). - const cutViolations = violationsOf(section); - if (cutViolations.length) { - const worst = cutViolations[0]; - const badge = document.createElement("span"); - badge.className = "b06-cross-card__warning"; - badge.textContent = `⚠ 절토 1:${worst.ratio.toFixed(2)}`; - badge.title = cutViolations - .map( - (item) => - `${item.side === "left" ? "좌" : "우"} 1:${item.ratio.toFixed(2)}` + - ` — ${limitLabel(item.limitKey)} 법정 1:${item.limit.min}~${item.limit.max}` + - ` 범위 밖(${item.kind === "steep" ? "너무 급함" : "너무 완만함"})` + - ` · ${item.startOffsetM.toFixed(2)}~${item.endOffsetM.toFixed(2)}m`, - ) - .join("\n"); - meta.append(badge); - } const structureName = section.structure; if (structureName) { const structure = document.createElement("span"); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 890019f0..3877af0a 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -2,7 +2,6 @@ import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; -import { rememberCutSlopeCriteria } from "./B06_Section_Cut_Slope_Check"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; @@ -656,8 +655,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); - // 절토 비탈 법정 기울기 기준(별표2)도 같은 방식으로 기억해 둔다 — 카드가 읽는다. - rememberCutSlopeCriteria(context); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index f9a81493..9279ab3b 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -26,7 +26,6 @@ import { } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; -import { applyRockQualityChoice } from "./B06_Section_Cut_Slope_Check"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, @@ -397,40 +396,8 @@ export function createStandardPanel( } actions.append(resetButton); - // ── 절토 법정 기울기 판정 기준: **암질** (2026-09-07 사용자 확정) ──────────── - // 「연암과 경암도 별도의 버튼으로 선택하게 하고 경암을 기본값으로. 리핑을 할지 발파를 - // 할지는 설계자가 선택 필요함」 — 축이 둘이다. **암질**(연암·경암)은 별표2 경사 판정의 - // 기준이고, **굴착 공법**(리핑암·발파암)은 수량·단가 몫이다. 한쪽에서 다른 쪽을 추론하지 - // 않는다. 절토 경사비가 오는 자리 옆이라 여기 둔다. - const rockQualityWrap = document.createElement("div"); - rockQualityWrap.className = "b06-std__cutclass"; - const rockQualityTitle = document.createElement("p"); - rockQualityTitle.className = "b06-std__cutclass-title"; - rockQualityTitle.textContent = "절토 법정 기울기 판정 암질 (별표2)"; - rockQualityTitle.title = - "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음.\n" + - "굴착 공법(리핑·발파)과는 다른 값이다 — 공법은 수량·단가에서 따로 고른다."; - rockQualityWrap.append(rockQualityTitle); - const rockQualityField = createSelectField({ - label: "암질", - options: [ - { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, - { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, - ], - value: state.rock_quality ?? "hard_rock", - onChange: (value) => { - state.rock_quality = value; - persist(); - applyRockQualityChoice(value); - onApplyAll?.(); - }, - }); - rockQualityWrap.append(rockQualityField.root); - // 처음 세울 때도 고른 값을 검사에 반영한다(세션에 남아 있던 선택 포함). - applyRockQualityChoice(state.rock_quality ?? "hard_rock"); - // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. - root.append(body, rockQualityWrap, loader, actions); + root.append(body, loader, actions); return { root, diff --git a/config/config_system_design.py b/config/config_system_design.py index d430961c..c239ab33 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -46,29 +46,6 @@ ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} -# ───────────────────────────────────────────────────────────────────────── -# 절토 비탈 법정 기울기 (별표2) -# -# 근거 — 지식DB `resources/knowledge/technical_info/01_임도/02_상세설계/절토_비탈면.md` §1. -# 그 문서의 [구현] 줄대로 **판정은 별표2 범위로** 한다(실무 관행값은 후보일 뿐 기준이 아님). -# 값은 수직 1 에 대한 수평(1:n 의 n). -# ───────────────────────────────────────────────────────────────────────── -FOREST_ROAD_CUT_SLOPE_LIMITS = { - "hard_rock": (0.3, 0.8), # 암석지 — 경암 - "soft_rock": (0.5, 1.2), # 암석지 — 연암 - "soil": (0.8, 1.5), # 토사지역 -} -# 절토 경사 판정에 쓸 **암질** 기본값 (2026-09-07 사용자 확정). -# -# ⚠ 축이 둘이다 — 섞지 말 것. -# · **암질**(연암·경암) = 별표2 경사 판정의 기준. 설계자가 고른다. 기본값 **경암**. -# · **굴착 공법**(리핑암·발파암, `cut_rock_kind`) = 어떻게 파는가. 수량·단가 몫이다. -# 한쪽에서 다른 쪽을 **추론하지 않는다**(사용자: 「리핑을 할지 발파를 할지는 설계자가 선택」). -# 처음에는 공법에서 암질을 유추하려 했으나 그것이 잘못 세운 문제였다. -FOREST_ROAD_CUT_SLOPE_ROCK_QUALITY_DEFAULT = "hard_rock" -# 절토 기울기 규정이 없는 등급 — 작업임도(별표2 §1 「작업임도: 절토 기울기 표 규정 없음」). -FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES = ("work",) - # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) -- 2.54.0 From c7770b75296003422027366858740f44db9b2b17 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:59:48 +0900 Subject: [PATCH 283/441] =?UTF-8?q?fix(=ED=9A=A1=EB=8B=A8):=20=EC=98=B9?= =?UTF-8?q?=EB=B2=BD=20=EC=9D=98=EB=AC=B4=20=ED=8C=90=EC=A0=95=EC=9D=B4=20?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=EC=9D=84=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=ED=95=A8=20=E2=80=94=20=EB=B6=80=ED=98=B8?= =?UTF-8?q?=C2=B7=EC=AA=BC=EA=B0=9C=EC=A7=90=20=EB=91=90=20=EA=B3=B3=20(?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=9C=203-9=20=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋으로는 화면 값이 안 바뀌어 실화면에서 두 가지를 더 찾았음. ① **TS 결과에 소단을 안 실었음** — 파이썬만 되싣고 브라우저 판을 빠뜨렸음. 판정(`fillSlopeLengths`)은 브라우저에서 도는데 `design.berm` 이 없어 종전 식으로 갔음. ② **소단을 폭으로 찾으면 못 찾음** — 설계선 꼭짓점에는 지반 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 **두 도막으로 쪼개짐**(실측: 평탄부 0개). 게다가 설계선은 오프셋 오름차순이라 **우측(음수) 성토면은 같은 소단이 `-기울기`로 나와** 부호를 그대로 대면 역시 못 찾음. ⇒ **기울기로 가름.** 소단은 2°(0.035), 성토는 1:1.2~2.0(0.5~0.83)이라 성토 기울기의 절반만 잡아도 확실히 갈림. 쪼개져도 부호가 뒤집혀도 걸림. 실화면 확인(8001·5174, `stale:false`) — 측점 2820.0m, 성토사면이 긴 자리: · 소단 없음 → **≥32.54m** (옹벽·석축 의무 대상) · 폭 0.5m·3m마다 놓음 → **≥3.00m** (5m 이내 — 의무 해소). 3.00m 은 소단 간격 그대로임 · 옆 측점(2840.0m, 안 놓음) → **≥32.54m 그대로** **소단 없는 측점은 값이 안 바뀜 — 272측점 전수 대조, 달라진 곳 0.** 구조로도 보장됨(`design.berm` 이 없으면 종전 식을 그대로 탐). 전체 542 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Fit.ts | 15 ++++++++++----- common_util/common_util_cross_design.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index af03913e..b5d09f6c 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -134,7 +134,12 @@ function longestFillRun( if (!berm) return Math.abs(endOffset - startOffset) * slant; const low = Math.min(startOffset, endOffset); const high = Math.max(startOffset, endOffset); - const bermRise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + // 소단인지는 **기울기**로 가른다 — 폭으로 찾으면 못 찾는다. 설계선 꼭짓점에는 지반 + // 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 두 도막으로 **쪼개지기** 때문이다 + // (2026-09-07 실측: 폭으로 찾으니 평탄부 0개). 소단 기울기는 2°(0.035)이고 성토 기울기는 + // 1:1.2~2.0(0.5~0.83)이라 절반만 잡아도 둘이 확실히 갈린다. + const fillGradient = 1 / Math.max(design.fill_slope_ratio, 1e-6); + const bermMaxGradient = fillGradient * 0.5; let longest = 0; let current = 0; const line = design.design_line; @@ -145,10 +150,10 @@ function longestFillRun( const to = Math.min(Math.max(a.offset_m, b.offset_m), high); if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다 const run = Math.abs(b.offset_m - a.offset_m); - const rise = b.elevation_m - a.elevation_m; - const isBerm = Math.abs(run - berm.width_m) < 1e-6 && Math.abs(rise - bermRise) < 1e-6; - if (isBerm) { - longest = Math.max(longest, current); + if (run <= 1e-9) continue; + const gradient = Math.abs(b.elevation_m - a.elevation_m) / run; + if (gradient < bermMaxGradient) { + longest = Math.max(longest, current); // 소단에서 도막이 끊긴다 current = 0; continue; } diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 83f060b0..a3c5c58a 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -134,6 +134,8 @@ export interface CrossDesignResult { design_line: CrossDesignEdge[]; /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ cut_slope_segments: CutSlopeSegment[]; + /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -440,6 +442,15 @@ export function computeCrossDesign( cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); + if (options.berm) { + // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 + // (`fillSlopeLengths`)이 이 값을 보고** 사면을 도막으로 끊어 잰다(계획서 3-9). + result.berm = { + width_m: round4(options.berm.widthM), + interval_m: round4(options.berm.intervalM), + slope_deg: round4(options.berm.slopeDeg), + }; + } if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); if (presetKey === "rock" && rockBoundaryOffsetM !== null) { result.rock_boundary_offset_m = round4(rockBoundaryOffsetM); -- 2.54.0 From 7833b9e677d01b6163f292e7f8c1aac5c4756014 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 17:29:50 +0900 Subject: [PATCH 284/441] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC):=20?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=EC=9D=84=20=E3=80=8C=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=E3=80=8D=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80=20=E2=80=94=20C=EA=B5=B0=20=EC=82=AC=EB=A9=B4?= =?UTF-8?q?=EC=95=88=EC=A0=95=20=EC=8B=A0=EC=84=A4=20(=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=9C=203-9,=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=AC?= =?UTF-8?q?=ED=99=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07) — 소단은 좌측 별도 폼이 아니라 **다른 옹벽·기슭막이와 같은 자리** (구조물 배치)에서 놓는 것으로 바뀜. 기하는 그대로 두고 **입구만 옮겼음.** · 레지스트리에 **C군(사면안정) 「소단」** 신설. 옵션은 다른 C군 구간형과 같은 꼴 — 길이·기준측점 전·후 + 폭·간격(사면길이)·안쪽 기울기. · ⚠ **소단은 C군 구간형이지만 벽이 아님** — 기슭막이 제원 자리에 얹히지 않게 파이썬·TS 양쪽 벽 필터에서 뺐음. 안 뺐으면 소단을 놓는 순간 「독립 기슭막이」로 그려졌을 것임. · 구조물 목록이 바뀌면 소단 구간을 세션 사본(`berm`)으로 펴고, 달라졌을 때만 재계산함. 계산 계통(브라우저·서버·`USER_TOUCHED_KEYS`·옹벽 의무 판정)은 **한 줄도 안 바꿨음**. · ④에서 만든 좌측 별도 「소단」 패널은 걷어냈음(모듈 삭제). **안쪽 기울기 기본값 2° → 0°** (사용자 재확정). 기울이는 것은 실무이나 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받음. 주석·지식DB 방침도 그에 맞춰 고침. **소단측구**(B군 종단배수)도 같은 꼴로 옵션을 붙였음 — 소단 위에 놓이는 시설이라 폭·간격이 소단을 따르고, 기울기만 **사면 쪽**(소단과 반대). 소단이 실제로 서면서 `PENDING_TYPE_IDS` 빈 칸이 풀려 연장 수량이 다른 구조물과 같은 길로 나옴. 자체검증 — 「근거 없는 기본값 금지」 시험에 두 타입 12개 기본값을 근거와 함께 등재. 소단측구 연장 시험을 「빈 칸」에서 「연장 100m」로 뒤집음. 전체 544 passed · 18 skipped. TS 타입 검사·ruff 통과. 화면 확인은 다음 단계. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 126 +++++++++++- .../B06_Section_Engine_Structures_Wall.py | 8 + B06_Section/B06_Section_Section_Store.ts | 6 +- B06_Section/B06_Section_UI_Berm_Panel.ts | 185 ------------------ B06_Section/B06_Section_UI_Page.ts | 33 ++-- B06_Section/B06_Section_UI_Page_Persist.ts | 51 +++++ common_util/common_util_cross_berm.py | 7 +- common_util/common_util_cross_berm.ts | 5 +- common_util/common_util_structure_lengths.py | 12 +- 9 files changed, 224 insertions(+), 209 deletions(-) delete mode 100644 B06_Section/B06_Section_UI_Berm_Panel.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f6862a92..3abed2df 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -505,10 +505,65 @@ "placement": "interval", "style": { "color": "#2ec5dc", - "abbr": "소단" + "abbr": "소단구" }, "drawing_views": ["cross_section", "quantity"], - "options": [] + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] }, { "type_id": "chute", @@ -910,6 +965,73 @@ } ] }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": ["profile", "cross_section", "quantity"], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, { "type_id": "erosion_check", "group": "D", diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index e4f9c143..d4b3a70d 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -39,6 +39,10 @@ _FORM_BY_TYPE = { } +# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). +BERM_TYPE_ID = "berm" + + def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" try: @@ -48,6 +52,10 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: definition = types.get(structure.type_id) if definition is None or definition.group != "C" or definition.placement != "interval": continue + # 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + # 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + if structure.type_id == BERM_TYPE_ID: + continue start, end = structure.start_m, structure.end_m if start is None or end is None: continue diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b82cef1a..38e54977 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -56,7 +56,11 @@ async function withDraftWalls( const types = await fetchStructureTypes().catch(() => []); const names = new Map( types - .filter((type) => type.group === "C" && type.placement === "interval") + // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + // 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + .filter( + (type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm", + ) .map((type) => [type.type_id, type.name] as const), ); if (!names.size) return detail; diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts deleted file mode 100644 index 4ee00280..00000000 --- a/B06_Section/B06_Section_UI_Berm_Panel.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Berm_Panel.ts - * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). - * - * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 - * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 - * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 - * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. - * - * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, - * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 - * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). - * ========================================================================== */ - -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import { - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, -} from "@util/common_util_cross_berm"; -import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; -import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; -import { buildGroup } from "./B06_Section_UI_Page_Common"; -import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; - -/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ -const DEFAULT_BEFORE_M = 5; -const DEFAULT_AFTER_M = 5; - -export interface BermPanelDeps { - /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ - getInterval: () => number; - /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ - target: () => { projectId: string; routeId: number } | null; - /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ - onChange: () => void; -} - -export interface BermPanel { - root: HTMLElement; - /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ - reload: () => void; -} - -function numberField( - label: string, - value: number, - step: string, -): ReturnType { - const field = createInputField({ label, type: "number" }); - field.input.step = step; - field.input.min = "0"; - field.input.value = String(value); - return field; -} - -export function createBermPanel(deps: BermPanelDeps): BermPanel { - const root = buildGroup("소단"); - - const anchor = stationFields("기준 측점", () => deps.getInterval()); - const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); - const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); - const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); - const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); - const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); - - const spanRow = document.createElement("div"); - spanRow.className = "b05-structure__grid"; - spanRow.append(beforeField.root, afterField.root); - - const specRow = document.createElement("div"); - specRow.className = "b05-structure__grid"; - specRow.append(widthField.root, intervalField.root); - - const slopeRow = document.createElement("div"); - slopeRow.className = "b05-structure__grid"; - slopeRow.append(slopeField.root); - - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - - let selected = -1; - - const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); - const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); - removeButton.classList.add("is-danger"); - removeButton.disabled = true; - const actions = document.createElement("div"); - actions.className = "b06-profile__field-row"; - actions.append(addButton, removeButton); - - root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); - - function spans(): BermSpan[] { - const target = deps.target(); - return target ? readBermSpans(target.projectId, target.routeId) : []; - } - - function save(next: BermSpan[]): void { - const target = deps.target(); - if (!target) return; - writeBermSpans(target.projectId, target.routeId, next); - render(); - deps.onChange(); - } - - function readNumber(field: { input: HTMLInputElement }, fallback: number): number { - const parsed = Number(field.input.value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - } - - function add(): void { - const interval = deps.getInterval(); - const chainage = anchor.read(interval, true); - if (chainage === null) { - showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); - return; - } - const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); - const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); - if (width <= 0 || gap <= 0) { - showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); - return; - } - const before = readNumber(beforeField, DEFAULT_BEFORE_M); - const after = readNumber(afterField, DEFAULT_AFTER_M); - save([ - ...spans(), - { - start_m: Math.max(chainage - before, 0), - end_m: chainage + after, - width_m: width, - interval_m: gap, - slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), - }, - ]); - selected = -1; - } - - function remove(): void { - if (selected < 0) return; - const next = spans().filter((_span, index) => index !== selected); - selected = -1; - save(next); - } - - function render(): void { - const interval = deps.getInterval(); - const current = spans(); - list.replaceChildren(); - removeButton.disabled = selected < 0 || selected >= current.length; - if (!current.length) { - const empty = document.createElement("li"); - empty.className = "b05-route__irregular-empty"; - empty.textContent = "놓은 소단이 없습니다."; - list.append(empty); - return; - } - current.forEach((span, index) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; - item.classList.toggle("is-selected", index === selected); - const where = document.createElement("strong"); - where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; - const what = document.createElement("span"); - what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; - item.append(where, what); - item.addEventListener("click", () => { - selected = index === selected ? -1 : index; - render(); - }); - list.append(item); - }); - } - - render(); - - return { - root, - reload(): void { - selected = -1; - render(); - }, - }; -} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 890019f0..43675dbd 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -24,11 +24,13 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; -import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { + bermSpansFromStructures, confirmCurrentSections, createRockBoundaryStore, + readBermSpans, saveCurrentSections, + writeBermSpans, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; @@ -150,7 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { reveal: () => layout.setOptionsOpen(true), // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. - onMarks: (structures, types) => structureMarksSink?.(structures, types), + onMarks: (structures, types) => { + structureMarksSink?.(structures, types); + syncBermSpans(structures); + }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -207,14 +212,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). - const bermPanel = createBermPanel({ - getInterval: () => stationInterval ?? 20, - target: () => - projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, - onChange: () => void reconcileStaleDesigns({ force: true }), - }); - leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); + leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -337,6 +335,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** + * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. + * + * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 + * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. + */ + function syncBermSpans(structures: ReadonlyArray): void { + if (!projectId || currentRouteId === null) return; + const next = bermSpansFromStructures(structures); + if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; + writeBermSpans(projectId, currentRouteId, next); + void reconcileStaleDesigns({ force: true }); + } + /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; @@ -726,7 +738,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); - bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..b5da86b4 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -22,6 +22,11 @@ import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structure import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -86,6 +91,52 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } +/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ +export const BERM_TYPE_ID = "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (item.type_id !== BERM_TYPE_ID) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + }); + } + return spans; +} + /** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { const found = spans.find( diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index ae79cb0b..892cb56d 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -15,8 +15,9 @@ 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. -· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 - **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). + 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. + 그래서 지식DB 에도 적지 않는다(사용자 지시). """ import math @@ -25,7 +26,7 @@ from typing import Callable, NamedTuple # 소단 기본값 — 근거는 위 모듈 설명. BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 -BERM_DEFAULT_SLOPE_DEG = 2.0 +BERM_DEFAULT_SLOPE_DEG = 0.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 5d97e807..7e88897b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -13,13 +13,14 @@ * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. - * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 + * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; -export const BERM_DEFAULT_SLOPE_DEG = 2.0; +export const BERM_DEFAULT_SLOPE_DEG = 0.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py index 092ff85e..43c617f8 100644 --- a/common_util/common_util_structure_lengths.py +++ b/common_util/common_util_structure_lengths.py @@ -16,9 +16,11 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 -# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). -PENDING_TYPE_IDS = frozenset({"ditch_berm"}) +# 아직 셈하지 않는 타입 — 지금은 없다. +# +# 소단측구(`ditch_berm`)는 계획서 3-9 로 **소단이 실제로 서면서 빈 칸이 풀렸다** +# (2026-09-07). 이제 다른 구조물과 같은 길로 연장이 나온다. +PENDING_TYPE_IDS: frozenset[str] = frozenset() def _merge(spans: list[tuple[float, float]]) -> float: @@ -48,11 +50,11 @@ def _merge(spans: list[tuple[float, float]]) -> float: def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). - 빼는 것 셋 — + 빼는 것 둘 — · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). - · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + (`PENDING_TYPE_IDS` 는 지금 비어 있다 — 소단측구가 3-9 로 풀렸다.) 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 (`StructureInstance.validate_placement_fields`). -- 2.54.0 From 6b9c115baa8f5bf07d385f41fd9efc90e5cdaec1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 17:52:06 +0900 Subject: [PATCH 285/441] =?UTF-8?q?feat(B06):=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EB=84=A3=EA=B2=8C=20=E2=80=94=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=95=94=20=EC=A0=88=ED=86=A0=EA=B0=81(=C2=B0),=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=8A=94=20=ED=91=9C=EC=A4=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07): 「대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영. 전체 공통으로 변경하는 경우에는 기본값 지정으로 하면 되지만 횡단도 하나만 변경하는 폼은 가져야함 / 개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함」 **화면** — 암 측점 카드 아래에 「암 절토 68.2° ↺」 칸이 섬. 값을 넣으면 그 측점만 사면이 새 경사로 다시 그려지고 절토량도 따라 바뀜. ↺ 는 표준값으로 되돌림. 전체를 바꾸는 자리는 종전대로 좌측 [표준 횡단면 설정]임. **계산에 넣는 자리는 한 곳씩** — 파이썬 `compute_cross_design(cut_slope_ratio=…)` · TS `computeCrossDesign({cutSlopeRatio})` 의 **그룹을 만든 바로 뒤**에서 경사비만 갈아 끼움. 부르는 쪽 9곳에서 표준값을 측점마다 복제하는 방식은 안 씀(한 곳만 빠져도 값이 조용히 사라지는 실패군). 기하·소단 코드는 한 줄도 안 건드림 — 25 확인대로 소단이 그 값을 읽어 서므로 **위치·개수가 새 경사를 저절로 따라옴**. ⚠ **경사는 나르는 값이 아니라 기하 입력임** — 계산 뒤에 키만 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 됨(소단에서 겪은 자리). 그래서 재계산 세 경로(포장 강제·세월교 하강· 선형 재계산)와 브라우저 재계산·서버 프리뷰 **모두 계산 인자로** 넘김. **되돌리기는 0 을 남김** — 세션에서 지우기만 하면 정본에 남은 옛 사용자 값이 되살아나 표준으로 못 돌아감. 0 = 「표준값을 씀」. 값의 길 — 세션 `cutslope`(등록표 한 줄) → 카드 입력 → [저장]·[확정]에서 `cross_patches` (`design.cut_slope_ratio_user`) → 정본. `USER_TOUCHED_KEYS` 양쪽에 넣어 **표준을 바꿔도 개별로 고친 측점은 그대로** 둠(사용자 원문 끝줄). 시험 — 새 7건(넣은 경사가 실제로 그려짐 · 무릎 위 토사 경사는 그대로 · 0 은 되돌림 · 재계산에도 남음 · **계산 전에 넘어감** · 각도↔경사비 · 칸은 암 측점에만), 거울 시험에 「암 절토 경사를 측점에서 바꿈」 한 갈래 추가, 7-3 회귀 한 줄 추가. 전체 **483 passed · 17 skipped**. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 + B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Api_Types.ts | 7 ++ B06_Section/B06_Section_Cross_Refresh.ts | 35 ++++++ B06_Section/B06_Section_Engine_Design.py | 8 ++ B06_Section/B06_Section_Router.py | 4 + B06_Section/B06_Section_Router_Design.py | 30 +++++ B06_Section/B06_Section_Schema.py | 10 ++ .../B06_Section_UI_Cross_Card_Chrome.ts | 11 +- B06_Section/B06_Section_UI_Cross_CutSlope.ts | 114 ++++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 5 +- B06_Section/B06_Section_UI_Page.ts | 15 +++ B06_Section/B06_Section_UI_Page_Patches.ts | 6 + B06_Section/B06_Section_UI_Page_Persist.ts | 91 ++++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 4 + B06_Section/B06_Section_UI_Style_Cross.css | 35 ++++++ common_util/common_util_cross_design.ts | 12 +- 17 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_CutSlope.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index fd30b73b..9c05c761 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). + * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 + * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ + cutslope: { bucket: "draft", scope: "route" }, /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 26e84d89..e427bf2d 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -255,6 +255,8 @@ export async function previewCrossDesigns( rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ berms?: Record; + /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ + cutSlopeRatios?: Record; }, ): Promise { return requestJson( @@ -267,6 +269,7 @@ export async function previewCrossDesigns( full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, berms: options?.berms ?? null, + cut_slope_ratios: options?.cutSlopeRatios ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 42dacd1b..c902c575 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -461,6 +461,9 @@ export interface CrossDesign { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). + * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ + cut_slope_ratio_user?: number; } export interface CrossDesignResponse { @@ -482,6 +485,8 @@ export interface CrossDesignRequest { rock_boundary_offset_m?: number | null; /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */ two_stage_slope?: boolean; + /** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */ + cut_slope_ratio?: number | null; /** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */ ditch_enabled?: boolean | null; /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ @@ -494,6 +499,8 @@ export interface CrossSectionPatch { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ display_half_width_m?: number; + /** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */ + cut_slope_ratio_user?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 6509d231..09951ca5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -42,6 +42,7 @@ import { toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { readState } from "../A00_Common/b_page_state"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -83,6 +84,8 @@ export const USER_TOUCHED_KEYS = [ "revet_follow_grade", // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. + "cut_slope_ratio_user", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -154,6 +157,23 @@ function bermPayload( return out; } +/** + * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. + * + * **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면 + * 저장분에 남은 옛 사용자 값이 되살아난다. + */ +function readCutSlopeRatios(projectId: string, routeId: number): Map { + const out = new Map(); + const raw = readState>("cutslope", projectId, routeId); + if (!raw) return out; + for (const [chainage, ratio] of Object.entries(raw)) { + const at = Number(chainage); + if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -184,6 +204,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); @@ -195,6 +218,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ + const cutSlopeAt = (chainageM: number, design: Record): number | null => { + const session = cutSlopeRatios.get(rockKey(chainageM)); + if (session !== undefined) return session > 0 ? session : null; + const stored = design.cut_slope_ratio_user; + return typeof stored === "number" && stored > 0 ? stored : null; + }; const choices = crossDesignChoices(projectId, input.routeId); const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); @@ -237,6 +267,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, + cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: choice?.two_stage_slope ?? (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), @@ -276,6 +307,10 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), berms: bermPayload(projectId, routeId, detail), + // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 + // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). + cutSlopeRatios: + readState>("cutslope", projectId, routeId) ?? undefined, }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7c7a86a7..be090efc 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -545,6 +545,7 @@ def compute_cross_design( standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, + cut_slope_ratio: float | None = None, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, plan_radius_m: float | None = None, @@ -561,6 +562,8 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 + None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 입력이며, None·45m 이상이면 확폭이 없다. curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 @@ -584,6 +587,11 @@ def compute_cross_design( if ditch_type == "l_type" and preset_key != "rock": raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") group = _resolve_group(preset_key, standard) + # 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + # ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만 + # 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + if cut_slope_ratio is not None and float(cut_slope_ratio) > 0: + group = {**group, "cut_slope_ratio": float(cut_slope_ratio)} paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 006ad692..1c445cd2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,7 @@ from B06_Section.B06_Section_Router_Design import ( ford_drop_at, ford_surface_drops, stored_berm, + stored_cut_slope, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -555,6 +556,7 @@ async def preview_cross_designs( request.rock_boundary_offsets, project_root, request.berms, + request.cut_slope_ratios, ) await asyncio.to_thread(rebuild) @@ -636,6 +638,8 @@ async def compute_cross_section_design( standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, + # 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07). + cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}), ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), berm=stored_berm(stored_design or {}), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f94d15e0..5f1aaf8a 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -140,6 +140,9 @@ USER_TOUCHED_KEYS = ( "revet_follow_grade", # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 + # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). + "cut_slope_ratio_user", ) @@ -193,6 +196,7 @@ def enforce_pavement_ranges( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=stored_berm(design), @@ -245,6 +249,7 @@ def enforce_ford_surface_drops( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, berm=stored_berm(design), @@ -346,6 +351,18 @@ def attach_default_designs( continue +def stored_cut_slope(stored: dict[str, Any]) -> float | None: + """저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None. + + ⚠ 소단과 같은 성격이다 — **경사는 나르는 값이 아니라 기하 입력**이라 계산에 넣어야 한다. + 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛 경사로 나오고 값만 새것이 되어 어긋난다. + """ + value = stored.get("cut_slope_ratio_user") + if isinstance(value, (int, float)) and float(value) > 0: + return float(value) + return None + + def stored_berm(stored: dict[str, Any]) -> BermSpec | None: """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" spec = stored.get("berm") @@ -369,6 +386,7 @@ def recompute_designs_for_alignment( rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, berms: dict[str, dict[str, float]] | None = None, + cut_slope_ratios: dict[str, float] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -378,6 +396,13 @@ def recompute_designs_for_alignment( round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } + # 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다. + session_cut_slopes: dict[float, float] = {} + for raw_key, ratio in (cut_slope_ratios or {}).items(): + try: + session_cut_slopes[round(float(raw_key), 3)] = float(ratio) + except (TypeError, ValueError): + continue session_offsets: dict[float, float] = {} for raw_key, offset in (rock_boundary_offsets or {}).items(): try: @@ -415,6 +440,11 @@ def recompute_designs_for_alignment( stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), two_stage_slope=bool(stored.get("two_stage_slope", True)), + cut_slope_ratio=( + (session_cut_slopes[key] or None) + if key in session_cut_slopes + else stored_cut_slope(stored) + ), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=session_berms.get(key) or stored_berm(stored), diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9b8dac61..8cf4525f 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -52,6 +52,9 @@ class CrossDesignRequest(BaseModel): rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. two_stage_slope: bool = True + # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # None 이면 표준 횡단면 설정값을 쓴다. + cut_slope_ratio: float | None = Field(default=None, gt=0) # 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override. ditch_enabled: bool | None = None # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. @@ -136,6 +139,10 @@ class CrossSectionPatch(BaseModel): rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). display_half_width_m: float | None = Field(default=None, gt=0) + # 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야 + # 정본에 남은 옛 사용자 값이 지워진다. + cut_slope_ratio_user: float | None = Field(default=None, ge=0) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). @@ -302,6 +309,9 @@ class CrossDesignPreviewRequest(BaseModel): # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. berms: dict[str, dict[str, float]] | None = None + # 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을 + # 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다. + cut_slope_ratios: dict[str, float] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..b6e1b8d5 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -8,6 +8,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; +import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { buildDesignControls, buildRockBoundaryControl, @@ -122,11 +123,12 @@ export function appendCardHeader( card.append(header); } -/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */ +/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */ export function appendCardFooter( card: HTMLElement, section: CrossSection, rockBoundary?: RockBoundaryControl, + cutSlope?: CutSlopeControl, ): void { const footer = document.createElement("footer"); const center = document.createElement("span"); @@ -138,5 +140,12 @@ export function appendCardFooter( rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } + // 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다 + // (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다. + if (cutSlope && section.design?.geometry_preset === "rock") { + const slopeControl = buildCutSlopeControl(section, cutSlope); + slopeControl.classList.add("b06-cross-card__cutslope"); + footer.append(slopeControl); + } card.append(footer); } diff --git a/B06_Section/B06_Section_UI_Cross_CutSlope.ts b/B06_Section/B06_Section_UI_Cross_CutSlope.ts new file mode 100644 index 00000000..23d1f842 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_CutSlope.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * B06_Section_UI_Cross_CutSlope.ts + * 측점 하나의 **암 절토 경사** 입력칸(2026-09-07 사용자 지시). + * + * 사용자 원문 — 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 + * 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점 + * 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다). + * + * 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의 + * 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + * + * ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은 + * 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다 + * (`computeCrossDesign(cutSlopeRatio)` · `compute_cross_design(cut_slope_ratio=…)`). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +/** 측점별 암 절토 경사 제어기 — Page 가 세션 저장소와 연결해 구현한다. */ +export interface CutSlopeControl { + /** 세션 → design 저장값 → 표준값 순으로 지금 경사비를 돌려준다. */ + ratioFor: (section: CrossSection) => number; + /** 이 측점의 표준값(되돌릴 자리) — 사용자가 안 만진 상태의 값. */ + standardRatioFor: (section: CrossSection) => number; + /** 경사비를 넣는다. null 이면 표준값으로 되돌린다. */ + set: (chainageM: number, ratio: number | null) => void; +} + +/** 경사비(1:n 의 n) → 수평에서 잰 각도(°). n 이 작을수록 급하다. */ +export function ratioToDegrees(ratio: number): number { + return (Math.atan(1 / Math.max(ratio, 1e-6)) * 180) / Math.PI; +} + +/** 각도(°) → 경사비(1:n 의 n). 1~89° 밖은 사면이 서지 않아 잘라 받는다. */ +export function degreesToRatio(degrees: number): number { + const clamped = Math.min(Math.max(degrees, 1), 89); + return 1 / Math.tan((clamped * Math.PI) / 180); +} + +/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */ +function sameDegrees(a: number, b: number): boolean { + return Math.abs(a - b) < 0.05; +} + +/** + * 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다. + * + * 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도 + * 따라간다(사용자가 만진 흔적이 지워지므로). + */ +export function buildCutSlopeControl(section: CrossSection, control: CutSlopeControl): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg b06-design__cutslope"; + const legend = document.createElement("span"); + legend.className = "b06-design__seg-legend"; + legend.textContent = "암 절토"; + wrap.append(legend); + + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const ratio = control.ratioFor(section); + const standard = control.standardRatioFor(section); + const degrees = ratioToDegrees(ratio); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "b06-design__cutslope-input"; + input.step = "0.5"; + input.min = "1"; + input.max = "89"; + input.value = degrees.toFixed(1); + input.title = + `이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` + + `지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` + + `전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`; + const commit = (): void => { + const entered = Number(input.value); + if (!Number.isFinite(entered)) { + input.value = degrees.toFixed(1); + return; + } + // 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다. + const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered); + control.set(section.chainage_m, next); + }; + input.addEventListener("change", commit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + }); + // 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도. + input.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const unit = document.createElement("span"); + unit.className = "b06-design__cutslope-unit"; + unit.textContent = "°"; + + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b06-design__rockb-btn is-reset"; + reset.textContent = "↺"; + reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`; + reset.disabled = sameDegrees(degrees, ratioToDegrees(standard)); + reset.addEventListener("click", (event) => { + event.stopPropagation(); + control.set(section.chainage_m, null); + }); + + group.append(input, unit, reset); + wrap.append(group); + return wrap; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f2ac7b13..b5c18b79 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -48,6 +48,7 @@ import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Do import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -150,6 +151,8 @@ export function createCrossSectionCard( box?: BoxControl, /** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */ plotBase?: CrossPlotBase | null, + /** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */ + cutSlope?: CutSlopeControl, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 @@ -704,7 +707,7 @@ export function createCrossSectionCard( card.append(chartWrap); } - appendCardFooter(card, section, rockBoundary); + appendCardFooter(card, section, rockBoundary, cutSlope); return card; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3877af0a..f127f66f 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -26,6 +26,7 @@ import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, + createCutSlopeStore, createRockBoundaryStore, saveCurrentSections, type SectionPersistContext, @@ -46,6 +47,7 @@ import { } from "./B06_Section_UI_Page_Pipe_Options"; import { createStandardPanel, + effectiveStandardCross, rememberRockBoundaryDefault, type StandardPanelController, } from "./B06_Section_UI_Standard_Panel"; @@ -423,6 +425,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const rockOffsets = rockStore.offsets; const rockBoundaryControl = rockStore.control; + // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. + // 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다. + const cutSlopeStore = createCutSlopeStore({ + sessionKey: () => stateKey("cutslope", projectId, currentRouteId), + standard: () => (projectId ? effectiveStandardCross(projectId) : null), + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + recompute: (chainageM) => recomputeIfRock(chainageM), + }); + const cutSlopeRatios = cutSlopeStore.ratios; + const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다. // 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다 @@ -452,6 +464,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.revetLink, stationControls.ford, stationControls.box, + cutSlopeStore.control, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); @@ -595,6 +608,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { flushCulvertOptions: () => stationControls.flushCulvertOptions(), patchSources: () => ({ rockOffsets, + cutSlopeRatios, stationWidths, inletStructures, basinAdjustments, @@ -665,6 +679,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; rockStore.load(); + cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. void structuresPanel.load().then(restoreStructurePick); diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index cdb5025f..4853d895 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -16,6 +16,8 @@ import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; export interface CrossPatchSources { /** 암 경계선 오프셋(누가거리 문자열 키). */ rockOffsets: Map; + /** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */ + cutSlopeRatios: Map; /** 측점 개별 표시 반폭(m). */ stationWidths: Map; inletStructures: Map; @@ -42,6 +44,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.rockOffsets.forEach((offset, chainage) => { patchFor(Number(chainage)).rock_boundary_offset_m = offset; }); + // 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다. + sources.cutSlopeRatios.forEach((ratio, chainage) => { + patchFor(Number(chainage)).cut_slope_ratio_user = ratio; + }); // 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다. sources.stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..57844d2f 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -30,6 +30,7 @@ import { import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; @@ -198,6 +199,96 @@ export function createRockBoundaryStore(options: { }; } +/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ +export interface CutSlopeStore { + /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. + * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 + * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ + ratios: Map; + control: CutSlopeControl; + load: () => void; +} + +/** + * 측점 하나만 다른 **암 절토 경사** 세션 저장소. + * + * 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서 + * `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다. + * + * ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고 + * 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다. + */ +export function createCutSlopeStore(options: { + sessionKey: () => string | null; + /** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */ + standard: () => StandardCrossSection | null; + refreshCard: (chainageM: number) => void; + /** 경사 변경 → 사면·소단·단면적 재계산. */ + recompute: (chainageM: number) => void; +}): CutSlopeStore { + const { sessionKey, standard, refreshCard, recompute } = options; + const ratios = new Map(); + const key = (chainageM: number): string => chainageM.toFixed(2); + + /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ + const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { + const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock"; + const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined; + const value = group?.cut_slope_ratio; + return typeof value === "number" && value > 0 ? value : 0.4; + }; + + function persist(): void { + const storageKey = sessionKey(); + if (!storageKey) return; + try { + writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + return { + ratios, + load(): void { + ratios.clear(); + const storageKey = sessionKey(); + if (!storageKey) return; + try { + const raw = readByKey(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, ratio]) => { + // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. + if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); + }); + } catch { + /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ + } + }, + control: { + standardRatioFor: (section) => standardRatio(section), + ratioFor: (section) => { + const session = ratios.get(key(section.chainage_m)); + if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. + if (typeof session === "number" && session > 0) return session; + const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) + ?.cut_slope_ratio_user; + if (typeof stored === "number" && stored > 0) return stored; + return standardRatio(section); + }, + set: (chainageM, ratio) => { + // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); + else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); + persist(); + refreshCard(chainageM); + recompute(chainageM); + }, + }, + }; +} + /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 5f75c087..0d5111e9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -13,6 +13,7 @@ * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -146,6 +147,8 @@ export function createSectionView( revetLink?: RevetLinkControl, ford?: FordControl, box?: BoxControl, + /** 측점별 암 절토 경사 입력(2026-09-07). */ + cutSlope?: CutSlopeControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -457,6 +460,7 @@ export function createSectionView( ford, box, plotBase, + cutSlope, ); /** diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2a4a874a..b2b89cd1 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -572,3 +572,38 @@ border-radius: var(--radius-inputs); padding: 1px 4px; } + +/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로 + 이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */ +.b06-cross-card__cutslope { + margin-left: auto; + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +.b06-design__cutslope-input { + width: 44px; + padding: 0 2px; + font-size: 0.72rem; + font-family: var(--font-mono); + text-align: right; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); +} + +/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ +.b06-design__cutslope-input::-webkit-outer-spin-button, +.b06-design__cutslope-input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.b06-design__cutslope-unit { + padding: 0 2px 0 1px; + font-size: 0.72rem; + color: var(--color-text-secondary); + align-self: center; +} diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 83f060b0..275fc89b 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -81,6 +81,9 @@ export interface CrossDesignOptions { standard: StandardCrossSectionSpec; rockBoundaryOffsetM?: number | null; twoStageSlope?: boolean; + /** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값. + * 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */ + cutSlopeRatio?: number | null; ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; @@ -250,7 +253,14 @@ export function computeCrossDesign( if (ditchType === "l_type" && presetKey !== "rock") { throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); } - const group = resolveGroup(presetKey, options.standard); + let group = resolveGroup(presetKey, options.standard); + // 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + // ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴 + // 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + const userCutRatio = options.cutSlopeRatio; + if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) { + group = { ...group, cut_slope_ratio: userCutRatio }; + } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; -- 2.54.0 From e7695c678f23fe4f3b76f0ce6e8606d5b788e1fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:05:46 +0900 Subject: [PATCH 286/441] =?UTF-8?q?feat(B06):=20=EC=A7=80=EB=B0=98?= =?UTF-8?q?=EC=9C=A0=ED=98=95=EC=9D=84=20=E3=80=8C=ED=86=A0=EC=82=AC?= =?UTF-8?q?=E3=80=8D=20=ED=86=A0=EA=B8=80=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=81=84=EB=A9=B4=20=EC=95=94(=EA=B8=B0=EB=B3=B8),?= =?UTF-8?q?=20=ED=91=9C=EC=A4=80=20=ED=8C=A8=EB=84=90=EC=97=90=20=EA=B0=81?= =?UTF-8?q?=EB=8F=84=20=EB=B3=91=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07): 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화) / 비활성화 상태에서는 암경계선이 나오고 암경계선 아래는 사용자가 지정한 암 절토각 반영. 이후는 토사 절토 각도를 반영 / 활성화 상태에서는 암반이 없으니 절토는 토사 절토 각도로만 구현」 **화면** — 카드 제목줄의 지반유형 버튼 셋(토사·리핑암·발파암)이 **「토사」 단추 하나**가 됨. 색으로 켜짐/꺼짐을 보이고 **기본은 꺼짐(암)**. 측구 토글과 같은 꼴임. 실화면 확인(용화 5601e828 · route 169 · 측점 1+0.0): · **암**(꺼짐) — 암 경계선·암 절토각 칸 보임, 절토 토사 1.99 + 암 1.91 = **3.90㎡** · **토사**(켜짐) — 두 칸 사라짐, 절토 **전량 토사 7.20㎡**(토사 경사가 더 완만해 더 팜) · 다시 끄면 3.90㎡ 로 정확히 원복 **저장값은 종전 그대로** — 꺼짐 = `ripping_rock`, 켜짐 = `soil`. 옛 자료의 `blasting_rock` 도 「암」으로 읽힘. ⚠ 유토곡선·수량은 이제 암을 **한 종류(리핑암)**로 잡음 — 연암:경암 · 발파암:리핑암을 가르는 것은 **설계내역에서 설계자가 비율로**(계획서 8-1). 표준 횡단면 설정의 절토·성토 경사 칸에 **각도를 함께 보임**(툴팁) — 카드가 도(°)로 받으므로 두 자리의 말이 갈리지 않게 함. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. ⑤ **표준을 바꿔도 개별로 고친 측점은 그대로**를 실화면에서 확인 — 1번 카드에 40° 를 넣고 표준 암 절토경사를 0.4 → 0.8 로 바꿔 [전체 측점 반영]: · 1번(사용자 값) **40.0° 유지 · 암 절토 5.66㎡ 그대로** · 2번(값 없음) 68.2° → **51.3°**(=1:0.8) · 암 절토 2.61 → 4.01㎡ · 표준을 되돌리자 둘 다 원상 복귀 시험 3건 더 — 토글 하나인지 · 기본이 암인지 · 옛 발파암 자료도 암 기하로 읽히는지. 전체 **486 passed · 17 skipped**. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Design.ts | 44 +++++++++++++++----- B06_Section/B06_Section_UI_Standard_Panel.ts | 14 +++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 8e691cb0..e6cc50f5 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -69,11 +69,19 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [ - ["soil", "B06_Design_Ground_Soil"], - ["ripping_rock", "B06_Design_Ground_Ripping"], - ["blasting_rock", "B06_Design_Ground_Blasting"], -]; +/** + * 지반유형은 **「토사」 토글 하나**다(2026-09-07 사용자 확정) — 켜면 토사, **끄면 암**(기본). + * + * 사용자 원문 — 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 + * 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화)」. 까닭은 **암반 지정·범위가 + * 실무에서 애매해** 측점마다 암질을 못 박는 것이 오히려 틀리기 때문이고, 연암:경암 · + * 발파암:리핑암은 **설계내역에서 설계자가 비율로** 넣기로 했다(계획서 8-1). + * + * ⚠ 저장값은 종전 그대로 쓴다 — 끔 = `ripping_rock`, 켬 = `soil`. 옛 자료의 `blasting_rock` + * 도 「암(끔)」으로 읽힌다. 유토곡선·수량은 암을 한 종류(리핑암)로 잡으며, 갈라 넣는 것은 + * 설계내역 몫이다. + */ +const GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"; const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ ["left_cut", "B06_Design_Mode_LeftCut"], ["right_cut", "B06_Design_Mode_RightCut"], @@ -321,7 +329,8 @@ export function buildDesignControls( twoStage: boolean; ditchEnabled: boolean | null; } = { - ground: design?.ground_type ?? "soil", + // 설계가 아직 없는 측점의 기본은 **암**이다(2026-09-07 사용자: 「기본값은 비활성화」). + ground: design?.ground_type ?? GROUND_ROCK_DEFAULT, mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", @@ -353,11 +362,24 @@ export function buildDesignControls( }); }; - // 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만. - const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => { - state.ground = value; - emit(); - }); + // 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07). + // 버튼명은 「토사」로 고정하고 **켜짐/꺼짐(색)**으로 상태를 보인다 — 측구 토글과 같은 꼴 + // (사용자 원문: 「토사버튼만 존재하고 이값은 활성화/비활성화로 반영」). + const groundToggle = toggle( + "", + state.ground === "soil", + L("B06_Design_Ground_Soil"), + L("B06_Design_Ground_Soil"), + () => { + state.ground = state.ground === "soil" ? GROUND_ROCK_DEFAULT : "soil"; + emit(); + }, + ); + groundToggle.button.title = + state.ground === "soil" + ? "토사 — 암반이 없어 절토는 토사 경사 하나로만 그린다. 누르면 암으로 바뀐다." + : "암 — 암 경계선이 서고 그 아래는 암 절토각, 위는 토사 경사로 그린다. 누르면 토사로 바뀐다."; + const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 9279ab3b..fed5075f 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -27,6 +27,7 @@ import { import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; +import { ratioToDegrees } from "./B06_Section_UI_Cross_CutSlope"; import { getCompanyStandard, listCompanyStandards, @@ -303,6 +304,19 @@ export function createStandardPanel( }); field.input.step = "0.1"; field.input.min = "0"; + // 경사 칸은 **각도도 함께 보인다** — 카드의 개별 절토각 칸이 도(°)로 받으므로 두 자리의 + // 말이 갈리지 않게 한다(2026-09-07). 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + if (spec.label === "B06_Std_Field_CutSlope" || spec.label === "B06_Std_Field_FillSlope") { + const showAngle = (): void => { + const ratio = Number(field.input.value); + field.input.title = + Number.isFinite(ratio) && ratio > 0 + ? `1:${ratio} = ${ratioToDegrees(ratio).toFixed(1)}° (횡단 카드의 각도 칸과 같은 값)` + : ""; + }; + showAngle(); + field.input.addEventListener("input", showAngle); + } grid.append(field.root); }; -- 2.54.0 From 674d4ef14a2bf2c422a0e926068c61d88ec28f23 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:08:18 +0900 Subject: [PATCH 287/441] =?UTF-8?q?fix(=ED=86=A0=EA=B3=B5):=20=EB=8F=84?= =?UTF-8?q?=EC=9E=90=20=EC=9A=B4=EB=B0=98=20=ED=95=9C=EA=B3=84=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=2070m=20=E2=86=92=2060m=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 근거 — 실무 오솔길 EARTH.DAT 헤더 6개 공사지가 전부 `20.0 / 60.0` (거창·장수·진안·봉화·영월 본선·지선). 산림과임업기술 5장 「다. 공사수량의 산출」도 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과(건설표준품셈 참조)」로 규정. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정). - config_system_design.py — EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 의 dozer 경계와 주석의 확정 이력·근거 갱신. 정의처가 이 상수 한 곳이라 다른 코드 변경 없음. - common_util_mass_haul_balance.ts — 경계현 설명 주석의 70m 표기 정정. 검증 — 백엔드 재시작 뒤 공용 브라우저에서 실제 API 호출, sections/context 의 dozer.max_distance_m = 60 확인. 회귀 369 passed (실패 3건은 haul 미참조 기존 깨짐). 지식DB 미결 No.21 해소 — 목록 정리는 위키 AI 몫. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_mass_haul_balance.ts | 2 +- config/config_system_design.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 31c454a7..1396df92 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -38,7 +38,7 @@ * 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량 * 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다. * 현 길이 20m 되는 높이 위쪽 → 종무대 - * 거기서 70m 되는 높이까지 → 도쟈 + * 거기서 60m 되는 높이까지 → 도쟈 * 그 아래 ~ 평형선 → 덤프 * 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다. * diff --git a/config/config_system_design.py b/config/config_system_design.py index c239ab33..6811f5c9 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -379,9 +379,13 @@ EARTHWORK_CONVERSION_FACTORS = { # 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` # **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. # -# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 +# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 60m < 덤프트럭 # -# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 +# 2026-09-07 사용자 확정값(도쟈 70 → 60m). 근거 — 실무 오솔길 `EARTH.DAT` 헤더 6개 +# 공사지가 전부 `20.0 / 60.0`(거창·장수·진안·봉화·영월 본선·지선), 산림과임업기술 +# 5장 「다. 공사수량의 산출」이 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과 +# (건설표준품셈 참조)」로 못 박음. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정). +# 유토곡선의 장비 경계현은 이 값을 # **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, # 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. # 현장·발주처 기준에 따라 달라질 수 있으므로 @@ -393,7 +397,7 @@ EARTHWORK_CONVERSION_FACTORS = { # ───────────────────────────────────────────────────────────────────────── EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( ("free_haul", 20.0), - ("dozer", 70.0), + ("dozer", 60.0), ("dump_truck", None), ) -- 2.54.0 From 4774eb3efb68d99f6b6c779a23abff2522b567e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:16:10 +0900 Subject: [PATCH 288/441] =?UTF-8?q?docs(=EC=86=8C=EB=8B=A8):=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=84=A4=EB=AA=85=20=EC=B2=AB=20=EC=A4=84=EC=9D=98?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=202=C2=B0?= =?UTF-8?q?=20=E2=86=92=200=C2=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아래 항목 설명은 0° 로 고쳐져 있었는데 머리말만 2° 로 남아 서로 어긋났음. 값 자체는 이미 0 이라 동작 변화 없음. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_cross_berm.py | 2 +- common_util/common_util_cross_berm.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 892cb56d..9649bbda 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -9,7 +9,7 @@ **바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 값이 나온다(거울 시험이 그것도 지킨다). -**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 7e88897b..bc12a09b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -8,7 +8,7 @@ * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. * - * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): -- 2.54.0 From 10b4b32a855e7f6a451f3fb22e782e02f93c8a37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:50:42 +0900 Subject: [PATCH 289/441] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B3=A1=EC=84=A0=EC=9D=84=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=EA=B0=80=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B2=8C=20=E2=80=94=20=EC=86=90=EB=8C=80?= =?UTF-8?q?=EB=A9=B4=20=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EC=82=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 ①② 의 뿌리 하나를 고침. 곡선을 서버만 그려서, 노드를 끌거나 손잡이를 살짝 건드리기만 해도 그려 둔 선·손잡이·노드 요약을 통째로 비웠음. 그 결과 화면에서 곡선이 전부 사라져 「R 이 지워졌다」로 보였음(값 자체는 남아 [확인] 때 반영됐음). - `buildEditedPolyline` 신설 — `build_planned_polyline` 의 **편집 갈래** (simplify=False + curve_flags/radii) 파이썬·TS 짝. 단순화·IP 추출·반지름 피팅은 초기 변환 전용이라 안 옮김. - `markEdited` 가 비우는 대신 **같은 규칙으로 다시 그림**. 나머지 곡선은 안 사라짐. - 손잡이는 곡선 목록 자리가 아니라 **노드 번호**로 잡음 — 다시 그릴 때마다 목록이 새로 나므로 자리로 들면 엉뚱한 곡선을 가리킴. - 접선 자리가 모자라 R 이 눌리는 것이 끄는 즉시 보임(상태줄 「기준 미달 N곳」). - 끌기 중에도 상태줄을 갱신 — 예전에는 아무 말이 없어 사라진 인상만 남았음. - 서버가 곡선을 안 둔 자리(내각 179° 이상)는 **곡선 없음으로 열게** 고침. 전부 켬으로 열면 아무것도 안 만지고 [확인]만 눌러도 곡선이 새로 생겨 노선이 조용히 바뀌었음. 거울 시험 `tmp/tests/test_route_polyline_browser_mirror.py` 10건 추가. 원호 표본 개수는 `math.sin` 의 마지막 자리 차이로 90° 처럼 딱 떨어지는 자리에서 하나 갈릴 수 있어(같은 원 위의 같은 호), 개수가 같을 때는 1e-9 로 자리까지, 갈릴 때는 현 하나분 안쪽으로 모양을 맞춤. 일부러 공식을 틀어 시험이 잡는 것도 확인함. 실화면(5174) — 노드/손잡이를 끈 뒤에도 상태줄이 「곡선 22곳(하한 R 12m)」 유지, 「기준 미달 2곳」이 그 자리에서 뜸. 종전에는 「곡선 기준 R 12m」로 바뀌며 다 사라졌음. 전체 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 67 ++--- B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts | 251 +++++++++++++++++- 2 files changed, 280 insertions(+), 38 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index fdf9159f..1d2b41fb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,7 +29,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; -import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -259,18 +259,21 @@ export async function openRouteEditModal( context.restore(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ - function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { - let best: { curve: number; end: "start" | "end" } | null = null; + /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. + * + * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 + * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ + function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve, index) => { + curveInfo.forEach((curve) => { (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; - best = { curve: index, end: which }; + best = { node: curve.node_first, end: which }; } }); }); @@ -279,13 +282,12 @@ export async function openRouteEditModal( /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( - curveIndex: number, + node: number, which: "start" | "end", to: Vertex, ): { apex: Vertex; radius: number } | null { - const curve = curveInfo[curveIndex]; + const curve = curveInfo.find((entry) => entry.node_first === node); if (!curve) return null; - const node = curve.node_first; const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; @@ -327,12 +329,17 @@ export async function openRouteEditModal( return best; } - /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. - * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). + * + * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 + * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. + * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 + * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { - plannedLine = []; - nodeInfo = []; - curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. + const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + plannedLine = built.vertices; + curveInfo = built.curves; + nodeInfo = built.nodes; } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ @@ -429,7 +436,7 @@ export async function openRouteEditModal( // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ - let dragHandle: { curve: number; end: "start" | "end" } | null = null; + let dragHandle: { node: number; end: "start" | "end" } | null = null; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener("pointerdown", (event) => { @@ -441,7 +448,7 @@ export async function openRouteEditModal( dragHandle = handleAt(px, py); dragNode = dragHandle ? -1 : nodeAt(px, py); if (dragHandle) { - picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + picked = dragHandle.node; syncCurveBar(); draw(); } else if (dragNode >= 0) { @@ -460,31 +467,28 @@ export async function openRouteEditModal( const py = event.clientY - rect.top; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). - const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + const node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { - const node = curveInfo[dragHandle.curve].node_first; planned[node] = moved.apex; curveRadius[node] = Math.round(moved.radius * 100) / 100; curveOn[node] = true; picked = node; - // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. - const which = dragHandle.end === "start" ? "start" : "end"; - curveInfo[dragHandle.curve] = { - ...curveInfo[dragHandle.curve], - apex: [moved.apex[0], moved.apex[1]], - radius_m: moved.radius, - [which]: toMetric(px, py), - } as (typeof curveInfo)[number]; - plannedLine = []; - nodeInfo = []; + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + markEdited(); syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); - markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. + markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; draw(); return; } @@ -654,7 +658,10 @@ export async function openRouteEditModal( inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); - curveOn.push(true); + // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 + // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 + // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. + curveOn.push(curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts index 49b97466..4a35d75b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -1,16 +1,57 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Curve.ts - * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 - * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. + * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) * - * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — - * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. - * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 - * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * 두 가지가 들어 있다. + * + * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는 + * 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * + * ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**: + * `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중 + * **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다. + * 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`. + * + * 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는 + * 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아 + * 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로 + * 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝). + * + * 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음 + * 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None). * ========================================================================== */ export type Vertex = [number, number]; +/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다. + * + * ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥 + * 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */ +export interface EditedCurve { + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + start: [number, number]; + end: [number, number]; + node_first: number; + node_last: number; + violations: string[]; +} + +/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ +const DUPLICATE_TOLERANCE_M = 0.5; +/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ +const ARC_STEP_DEG = 5.0; +/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 + * 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */ +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; +/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ +export const HAIRPIN_MIN_RADIUS_M = 10.0; + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { const dx1 = a2[0] - a1[0]; @@ -33,7 +74,7 @@ export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number const lb = Math.hypot(bx, by); if (la <= 0 || lb <= 0) return 180; const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); - return (Math.acos(cosine) * 180) / Math.PI; + return Math.acos(cosine) * DEG_PER_RAD; } /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. @@ -59,10 +100,204 @@ export function dragHandleTo( : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 if (!apex) return null; const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); if (!(halfTan > 1e-9)) return null; const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); const radius = tangent / halfTan; if (!(radius > 0) || !Number.isFinite(radius)) return null; return { apex, radius }; } + +const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */ +function unit(from: Vertex, to: Vertex): Vertex { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const length = Math.hypot(dx, dy); + if (length <= 0) return [0, 0]; + return [dx / length, dy / length]; +} + +/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */ +function turnSign(before: Vertex, at: Vertex, after: Vertex): number { + const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]); + if (Math.abs(cross) <= 1e-9) return 0; + return cross > 0 ? 1 : -1; +} + +/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */ +function arcGeometry( + before: Vertex, + at: Vertex, + after: Vertex, + innerDeg: number, + radius: number, + halfTan: number, +): { start: Vertex; end: Vertex; center: Vertex } | null { + const tangent = radius * halfTan; + const toBefore = unit(at, before); + const toAfter = unit(at, after); + const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent]; + const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent]; + const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + if (bisectorLength <= 1e-9) return null; + const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2); + const center: Vertex = [ + at[0] + (bisector[0] / bisectorLength) * centerDistance, + at[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + return { start, end, center }; +} + +/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */ +function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] { + const radius = distance(center, start); + if (radius <= 0) return []; + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep > 0) sweep -= 2 * Math.PI; + } else { + while (sweep < 0) sweep += 2 * Math.PI; + } + const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG)); + const points: Vertex[] = []; + for (let step = 1; step < steps; step += 1) { + const angle = startAngle + (sweep * step) / steps; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ +export interface EditedNode { + inner_angle_deg: number | null; + radius_m: number | null; + tangent_m: number | null; + violations: string[]; +} + +export interface EditedPolyline { + /** 그려 보이는 선(원호 포함). */ + vertices: Vertex[]; + /** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */ + curves: EditedCurve[]; + /** 넘긴 목록과 **자리가 같은** 노드 요약. */ + nodes: EditedNode[]; +} + +/** + * 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다. + * + * `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다). + * `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다. + * 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙). + * + * ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는 + * **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다. + */ +export function buildEditedPolyline( + points: Vertex[], + curveOn: boolean[], + curveRadius: Array, + minRadiusM: number, + hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M, +): EditedPolyline { + const nodes: EditedNode[] = points.map(() => ({ + inner_angle_deg: null, + radius_m: null, + tangent_m: null, + violations: [], + })); + + // 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다. + const cleaned: Vertex[] = []; + const flags: boolean[] = []; + const forcedRadii: Array = []; + const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리 + points.forEach((point, index) => { + if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) { + return; + } + cleaned.push(point); + flags.push(curveOn[index] !== false); + forcedRadii.push(curveRadius[index] ?? null); + origin.push(index); + }); + + if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes }; + + for (let index = 1; index < cleaned.length - 1; index += 1) { + nodes[origin[index]].inner_angle_deg = innerAngleDeg( + cleaned[index - 1], + cleaned[index], + cleaned[index + 1], + ); + } + + const vertices: Vertex[] = [cleaned[0]]; + const curves: EditedCurve[] = []; + let cursor = 0; // 아직 선에 안 실은 첫 꺾임점 + + for (let at = 1; at < cleaned.length - 1; at += 1) { + if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다. + const entryFrom = cleaned[at - 1]; + const apex = cleaned[at]; + const exitTo = cleaned[at + 1]; + const node = nodes[origin[at]]; + + const inner = innerAngleDeg(entryFrom, apex, exitTo); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); + // 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2; + if (!(halfTan > 1e-9) || available <= 0) continue; + + const forced = forcedRadii[at]; + let radius = forced !== null && forced > 0 ? forced : minRadiusM; + let tangent = radius * halfTan; + if (tangent > available) { + radius = available / halfTan; + tangent = available; + } + + const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan); + if (radius <= 0 || geometry === null) continue; + + if (radius < minRadiusM) { + node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + } + if (radius < hairpinMinRadiusM) { + node.violations.push( + `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, + ); + } + node.radius_m = radius; + node.tangent_m = tangent; + curves.push({ + apex: [apex[0], apex[1]], + radius_m: radius, + tangent_m: tangent, + inner_angle_deg: inner, + start: [geometry.start[0], geometry.start[1]], + end: [geometry.end[0], geometry.end[1]], + node_first: origin[at], + node_last: origin[at], + violations: [...node.violations], + }); + + // 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다. + for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]); + cursor = at; + + const clockwise = turnSign(entryFrom, apex, exitTo) < 0; + vertices.push(geometry.start); + vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise)); + vertices.push(geometry.end); + } + + for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]); + return { vertices, curves, nodes }; +} -- 2.54.0 From d03b46e22ecb23849be12dae5f03f0ec592a7c0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:07:53 +0900 Subject: [PATCH 290/441] =?UTF-8?q?chore(db):=20=EC=95=88=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EB=B9=88=20=ED=91=9C=20audit=5Flogs=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=E2=80=94=20=EC=A0=95=EB=B3=B8=EC=9D=80=20system=5F?= =?UTF-8?q?audit=5Flogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07 「묵은 것 청소」). 이름이 비슷한 표가 둘이라 **「감사 기록이 비었다」는 오진이 실제로 한 번 났던** 자리임. 지우기 전 확인(공용 DB) — `audit_logs` **0행** · 정본 `system_audit_logs` **81행** · 이 표를 참조하는 **외래키 0건** · 코드는 쓰기(`common_util_audit.py:61`) · 읽기 (`B01_Dashboard_Repository.py:455·460`) · 정리(`purge_expired_audit_logs`) 전부 정본만 씀. 최초 스키마(`001_create_schema.sql`)는 **안 고침** — 지나간 이력은 다시 쓰지 않는 것이 규칙임. 새로 설치하면 001 이 만들고 이 파일이 지움. 공용 DB 라 **적용 전 두 창(보조 워크트리 · 데스크톱)에 알리고** 「막을 것 없음」을 받은 뒤 돌림. 서버 재시작 없음. 적용 후 확인 — 표 사라짐, 정본 81행 그대로. Co-Authored-By: Claude Opus 5 (1M context) --- db_management/019_drop_legacy_audit_logs.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 db_management/019_drop_legacy_audit_logs.sql diff --git a/db_management/019_drop_legacy_audit_logs.sql b/db_management/019_drop_legacy_audit_logs.sql new file mode 100644 index 00000000..0cbd4343 --- /dev/null +++ b/db_management/019_drop_legacy_audit_logs.sql @@ -0,0 +1,16 @@ +-- 019_drop_legacy_audit_logs.sql +-- 안 쓰는 빈 표 `audit_logs` 를 지운다 (계획서 0-8 곁가지, 2026-09-07 사용자 지시). +-- +-- 왜 — 감사 기록의 **정본은 `system_audit_logs`** 다(`004_dashboard.sql`). `audit_logs` 는 +-- 최초 스키마(`001_create_schema.sql`)에만 있고 **코드가 한 곳도 쓰지 않는다** +-- (쓰기 `common_util_audit.py:61`, 읽기 `B01_Dashboard_Repository.py:455·460`, 정리 +-- `purge_expired_audit_logs` 모두 `system_audit_logs`). 표가 둘이라 「감사 기록이 비었다」는 +-- 오진이 실제로 한 번 났다(2026-09-07). +-- +-- 지우기 전 확인(2026-09-07, 공용 DB) — `audit_logs` **0행** · `system_audit_logs` 81행 · +-- 이 표를 참조하는 **외래키 0건**. 그래서 지워도 잃는 자료가 없다. +-- +-- ⚠ 최초 스키마 파일은 **고치지 않았다** — 지나간 이력이라 다시 쓰지 않는 것이 규칙이다. +-- 새로 설치하면 001 이 만들고 이 파일이 지운다. + +DROP TABLE IF EXISTS audit_logs; -- 2.54.0 From f84b3d9451670e3080eadfe64accc515ea4e7137 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:10:07 +0900 Subject: [PATCH 291/441] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=E2=80=94=20R=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EB=A5=B8=20=EC=9E=90=EB=A6=AC=20=EC=98=86?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C,=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=C2=B7=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=C2=B7=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94,=20=EC=A4=8C=C2=B7=ED=8C=AC=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 ③④⑤⑥ 과 추가 지시(되돌리기)를 한 묶음으로 처리. - **③ R 라벨** — 모달 맨 아랫줄이던 곡선 편집칸을 걷고, 고른 꺾임점 **옆에 뜨는 라벨**로 바꿈(`_Label.ts` 신설). 캔버스 밖으로 나가면 반대쪽으로 접어 넣음. 확대·이동·창 크기가 바뀌어도 고른 노드를 따라감. - **④ 노드가 손잡이보다 먼저 잡히게** — 반대였던 탓에 헤어핀처럼 곡선이 몰린 데서는 노드를 아예 못 집었음. 손잡이는 **고른 곡선에만** 그림. 머리말 안내에 조작 여섯을 적음. - **⑤ 줌·팬을 배수유역도와 동일하게**(`_Input.ts` 신설) — 휠은 **당기면 확대**(반대였음), 계수 1.15/0.87, 상한은 화면 폭 16m 기준, 하한 0.5, **팬은 가운데 버튼 전용**. ⚠ 커서 고정 계산이 좌상단 기준이라 확대할수록 지점이 밀리던 것도 중심 기준으로 고침. - **⑥ 등고선을 노선 ±300m 띠로** — 창 크기와 무관한 고정 띠라 창을 늘려도 안 깨짐. 화면 밖 걸러내기는 종전대로 `drawPreparedLayer` 가 함. - **되돌리기·다시하기·초기화**(`_History.ts` 신설) — [확인]이 무거워 되돌릴 길이 없으므로 창 안에서 물릴 수 있게 함. **끌기 한 번이 한 걸음**이고 클릭만으로는 걸음이 안 생김. [초기화]는 **이 창을 연 상태**로 (≠ [예상노선으로]). Ctrl+Z / Ctrl+Y · Ctrl+Shift+Z. - 700줄 제한 — 집기(hit test)를 `_Input.ts` 로 옮겨 본체 695줄. 실화면 검증(5174) — 라벨이 노드 오른쪽 [+18,−38]px 에 뜸 / 휠 당기니 중심에서 373.1 → 428.1px 로 확대 / 왼쪽 끌기로는 지도 안 움직임 / 가운데 버튼 끌기는 [60,40] 그대로 따라옴 / 단추 켜짐이 여섯 단계 모두 맞음. 전체 554 passed · 18 skipped, tsc 통과. 정본 안 건드림. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 360 +++++++++--------- .../B05_Profile_UI_RouteEdit_History.ts | 161 ++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 183 +++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 120 ++++++ .../B05_Profile_UI_Style_RouteEdit.css | 60 ++- 5 files changed, 684 insertions(+), 200 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_History.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Input.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Label.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 1d2b41fb..c54db379 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -30,14 +30,33 @@ import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { + bindRouteEditNavigation, + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + bindHistoryControls, + createRouteEditHistory, + type RouteEditHistory, + type RouteEditSnapshot, +} from "./B05_Profile_UI_RouteEdit_History"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; /** 노드 반지름(px). */ const NODE_R = 4; -/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ -const DRAG_THRESHOLD_PX = 3; +/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ +const SEGMENT_HIT_PX = 12; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; @@ -56,28 +75,24 @@ export async function openRouteEditModal(
계획노선 편집 - 노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제. + 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · + 노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
-
노선을 읽는 중… 예상노선(원본) 계획노선 + + + @@ -110,6 +125,8 @@ export async function openRouteEditModal( let curveRadius: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ + let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -125,6 +142,7 @@ export async function openRouteEditModal( const close = (): void => { closed = true; window.removeEventListener("resize", resize); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -181,6 +199,34 @@ export async function openRouteEditModal( context.restore(); } + /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 + * 아직 없으면 null(전체를 그린다). */ + function contourBandRect(): { x: number; y: number; width: number; height: number } | null { + const line = plannedLine.length ? plannedLine : planned; + if (!meta || line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); + const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; + } + function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -189,6 +235,14 @@ export async function openRouteEditModal( context.fillRect(0, 0, view.width, view.height); context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = contourBandRect(); + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; context.lineWidth = 0.8; for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); @@ -239,6 +293,9 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { + // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 + // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. + if (curve.node_first !== picked) return; const on = curveOn[curve.node_first] !== false; if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. [curve.start, curve.end].forEach((point) => { @@ -257,28 +314,17 @@ export async function openRouteEditModal( }); }); context.restore(); + // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. + syncCurveBar(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. - * - * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 - * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ - function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { - let best: { node: number; end: "start" | "end" } | null = null; - let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve) => { - (["start", "end"] as const).forEach((which) => { - const point = which === "start" ? curve.start : curve.end; - const [x, y] = toScreen([point[0], point[1]]); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) { - bestDistance = distance; - best = { node: curve.node_first, end: which }; - } - }); - }); - return best; - } + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => + handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + const nodeAt = (px: number, py: number): number => + nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); + const segmentAt = (px: number, py: number): number => + segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX); /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( @@ -294,41 +340,6 @@ export async function openRouteEditModal( return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); } - /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ - function nodeAt(px: number, py: number): number { - let best = -1; - let bestDistance = NODE_HIT_PX; - planned.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) { - bestDistance = distance; - best = index; - } - }); - return best; - } - - /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */ - function segmentAt(px: number, py: number): number { - let best = -1; - let bestDistance = 12; - for (let index = 0; index < planned.length - 1; index += 1) { - const [ax, ay] = toScreen(planned[index]); - const [bx, by] = toScreen(planned[index + 1]); - const dx = bx - ax; - const dy = by - ay; - const lengthSquared = dx * dx + dy * dy || 1; - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); - const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); - if (distance < bestDistance) { - bestDistance = distance; - best = index; - } - } - return best; - } - /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 @@ -363,100 +374,101 @@ export async function openRouteEditModal( ); } - // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── - const curveBar = overlay.querySelector(".b05-routeedit__curve")!; - const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; - const curveRadiusInput = curveBar.querySelector( - ".b05-routeedit__curve-radius", - )!; - const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; - const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; - const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; - const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + const curveLabelBox = createCurveLabel(canvas.parentElement!, { + onRadius: (value) => { + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onCurveOn: (on) => { + if (picked < 0) return; + curveOn[picked] = on; + applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + onAuto: () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + applyEdit("반지름을 자동으로 되돌렸습니다."); + }, + }); - /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ function syncCurveBar(): void { - const editable = picked > 0 && picked < planned.length - 1; - curveBar.hidden = !editable; - if (!editable) return; - const on = curveOn[picked] !== false; - curveLabel.textContent = `${picked + 1}번째 꺾임점`; - curveOffBtn.hidden = !on; - curveOnBtn.hidden = on; - curveRadiusInput.disabled = !on; - curveAutoBtn.disabled = !on || curveRadius[picked] === null; + if (!(picked > 0 && picked < planned.length - 1)) { + curveLabelBox.hide(); + return; + } const forced = curveRadius[picked]; - const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; - curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); - const inner = nodeInfo[picked]?.inner_angle_deg; - curveInfoText.textContent = on - ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + - ` · 법정 하한 ${minRadiusM}m` - : "곡선 없음 — 직선이 그대로 꺾입니다"; + curveLabelBox.show({ + seat: picked, + at: toScreen(planned[picked]), + canvas: { width: view.width, height: view.height }, + curveOn: curveOn[picked] !== false, + radiusShown: + forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + forced: forced !== null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + minRadiusM, + }); } - curveRadiusInput.addEventListener("change", () => { - if (picked < 0) return; - const value = Number(curveRadiusInput.value); - curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; - // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ + function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; draw(); + if (record) history?.commit(snapshotNow()); + historyControls.sync(); + } + + /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ + function snapshotNow(): RouteEditSnapshot { + return { planned, curveOn, curveRadius, picked }; + } + + const historyControls = bindHistoryControls({ + overlay, + getHistory: () => history, + restore: (snapshot, message) => { + planned = snapshot.planned; + curveOn = snapshot.curveOn; + curveRadius = snapshot.curveRadius; + picked = snapshot.picked; + applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. + }, }); - curveOffBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = false; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; - draw(); - }); - - curveOnBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = true; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; - draw(); - }); - - curveAutoBtn.addEventListener("click", () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; - draw(); - }); - - // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. let dragNode = -1; - /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ let dragHandle: { node: number; end: "start" | "end" } | null = null; - let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 + * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ + let dragMoved = false; canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; - // 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작). - dragHandle = handleAt(px, py); - dragNode = dragHandle ? -1 : nodeAt(px, py); - if (dragHandle) { + // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 + // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 + // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. + dragNode = nodeAt(px, py); + dragHandle = dragNode >= 0 ? null : handleAt(px, py); + dragMoved = false; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else if (dragHandle) { picked = dragHandle.node; syncCurveBar(); draw(); - } else if (dragNode >= 0) { - picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. - syncCurveBar(); - draw(); - } else { - panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; } canvas.setPointerCapture(event.pointerId); }); @@ -476,6 +488,7 @@ export async function openRouteEditModal( picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; @@ -484,6 +497,7 @@ export async function openRouteEditModal( return; } if (dragNode >= 0) { + dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 @@ -492,24 +506,20 @@ export async function openRouteEditModal( draw(); return; } - if (panFrom) { - if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return; - view = { - ...view, - offsetX: panFrom.offsetX + (px - panFrom.x), - offsetY: panFrom.offsetY + (py - panFrom.y), - }; - draw(); - return; - } - canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default"; + canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 + // 눌러야 한다. 놓는 순간에만 쌓는다. + if (dragMoved) { + history?.commit(snapshotNow()); + historyControls.sync(); + } dragNode = -1; dragHandle = null; - panFrom = null; + dragMoved = false; }; canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); @@ -525,10 +535,7 @@ export async function openRouteEditModal( curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); picked = segment + 1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; - draw(); + applyEdit("새 노드를 넣었습니다(직선 추가)."); }); canvas.addEventListener("contextmenu", (event) => { @@ -544,33 +551,19 @@ export async function openRouteEditModal( curveOn.splice(index, 1); curveRadius.splice(index, 1); picked = -1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; - draw(); + applyEdit("노드를 지웠습니다(직선 삭제)."); }); - canvas.addEventListener( - "wheel", - (event) => { - event.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; - const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2; - const nextScale = Math.max(1, Math.min(2000, view.scale * factor)); - const ratio = nextScale / view.scale; - // 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다. - view = { - ...view, - scale: nextScale, - offsetX: px - (px - view.offsetX) * ratio, - offsetY: py - (py - view.offsetY) * ratio, - }; - draw(); + // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => { + view = next; }, - { passive: false }, - ); + getMeta: () => meta, + draw, + }); async function runHeavy(label: string, task: () => Promise): Promise { busy.hidden = false; @@ -667,6 +660,9 @@ export async function openRouteEditModal( if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); }); picked = -1; + // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + history = createRouteEditHistory(snapshotNow()); + historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts new file mode 100644 index 00000000..a460c458 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_History.ts + * 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시). + * + * 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니 + * **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로 + * 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다. + * + * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 + * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 되돌릴 수 있는 편집 상태 한 벌. */ +export interface RouteEditSnapshot { + planned: Vertex[]; + curveOn: boolean[]; + curveRadius: Array; + picked: number; +} + +/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ +const MAX_STEPS = 100; + +export interface RouteEditHistory { + /** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */ + commit: (snapshot: RouteEditSnapshot) => void; + /** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */ + undo: () => RouteEditSnapshot | null; + /** 한 걸음 앞으로. 없으면 null. */ + redo: () => RouteEditSnapshot | null; + /** 창을 연 상태로. 이미 그 상태면 null. */ + reset: () => RouteEditSnapshot | null; + canUndo: () => boolean; + canRedo: () => boolean; + /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ + isDirty: () => boolean; +} + +/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ +function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { + return { + planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), + curveOn: [...snapshot.curveOn], + curveRadius: [...snapshot.curveRadius], + picked: snapshot.picked, + }; +} + +/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ +function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { + if (a.planned.length !== b.planned.length) return false; + for (let index = 0; index < a.planned.length; index += 1) { + if (a.planned[index][0] !== b.planned[index][0]) return false; + if (a.planned[index][1] !== b.planned[index][1]) return false; + if (a.curveOn[index] !== b.curveOn[index]) return false; + if (a.curveRadius[index] !== b.curveRadius[index]) return false; + } + return true; +} + +/** 첫 사진(창을 연 상태)으로 이력을 연다. */ +export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { + const steps: RouteEditSnapshot[] = [clone(initial)]; + let at = 0; + + return { + commit(snapshot) { + // 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로. + steps.length = at + 1; + steps.push(clone(snapshot)); + if (steps.length > MAX_STEPS) steps.shift(); + at = steps.length - 1; + }, + undo() { + if (at <= 0) return null; + at -= 1; + return clone(steps[at]); + }, + redo() { + if (at >= steps.length - 1) return null; + at += 1; + return clone(steps[at]); + }, + reset() { + // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. + if (sameRoute(steps[at], steps[0])) return null; + // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. + steps.length = at + 1; + steps.push(clone(steps[0])); + at = steps.length - 1; + return clone(steps[at]); + }, + canUndo: () => at > 0, + canRedo: () => at < steps.length - 1, + isDirty: () => !sameRoute(steps[at], steps[0]), + }; +} + +export interface HistoryControlsParams { + /** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */ + overlay: HTMLElement; + /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ + getHistory: () => RouteEditHistory | null; + /** 사진 한 벌을 화면에 되살린다. */ + restore: (snapshot: RouteEditSnapshot, message: string) => void; +} + +/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. + * + * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 + * 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ +export function bindHistoryControls(params: HistoryControlsParams): { + sync: () => void; + dispose: () => void; +} { + const { overlay, getHistory, restore } = params; + const undoBtn = overlay.querySelector('[data-act="undo"]')!; + const redoBtn = overlay.querySelector('[data-act="redo"]')!; + const resetBtn = overlay.querySelector('[data-act="history-reset"]')!; + + const sync = (): void => { + const history = getHistory(); + undoBtn.disabled = !history?.canUndo(); + redoBtn.disabled = !history?.canRedo(); + resetBtn.disabled = !history?.isDirty(); + }; + + const step = (which: "undo" | "redo" | "reset"): void => { + const history = getHistory(); + if (!history) return; + const snapshot = history[which](); + if (!snapshot) return; + restore( + snapshot, + which === "undo" + ? "되돌렸습니다." + : which === "redo" + ? "다시 했습니다." + : "창을 연 상태로 돌렸습니다.", + ); + sync(); + }; + + undoBtn.addEventListener("click", () => step("undo")); + redoBtn.addEventListener("click", () => step("redo")); + resetBtn.addEventListener("click", () => step("reset")); + + const onKey = (event: KeyboardEvent): void => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "z" && !event.shiftKey) step("undo"); + else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + + return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts new file mode 100644 index 00000000..a73dd52e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Input.ts + * 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬. + * + * ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다 + * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. + * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 + * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 + * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 + * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. + * + * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 + * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). + * ========================================================================== */ + +import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */ +const ZOOM_IN_FACTOR = 1.15; +const ZOOM_OUT_FACTOR = 0.87; +/** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */ +const MIN_SCALE = 0.5; +/** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */ +const MAX_SCALE_FALLBACK = 16; + +export interface RouteEditNavigationParams { + canvas: HTMLCanvasElement; + getView: () => ViewState; + setView: (next: ViewState) => void; + getMeta: () => VWorldMeta | null; + draw: () => void; +} + +/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */ +export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { + const { canvas, getView, setView, getMeta, draw } = params; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const view = getView(); + const maxScale = computeMaxScale( + getMeta(), + view.mapRect.width, + view.width, + MAX_SCALE_FALLBACK, + ); + const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; + const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); + const ratio = scale / view.scale; + const rect = canvas.getBoundingClientRect(); + // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + setView({ + ...view, + scale, + offsetX: cursorX * (1 - ratio) + view.offsetX * ratio, + offsetY: cursorY * (1 - ratio) + view.offsetY * ratio, + }); + draw(); + }, + { passive: false }, + ); + + canvas.addEventListener("pointerdown", (event) => { + // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. + if (event.pointerType === "mouse" && event.button !== 1) return; + const view = getView(); + dragStart = { + x: event.clientX, + y: event.clientY, + offsetX: view.offsetX, + offsetY: view.offsetY, + }; + canvas.style.cursor = "grabbing"; + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + if (!dragStart) return; + setView({ + ...getView(), + offsetX: dragStart.offsetX + event.clientX - dragStart.x, + offsetY: dragStart.offsetY + event.clientY - dragStart.y, + }); + draw(); + }); + + const stop = (): void => { + if (!dragStart) return; + dragStart = null; + canvas.style.removeProperty("cursor"); + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); +} + +/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ + +export type ScreenOf = (point: [number, number]) => [number, number]; + +/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ +export function nodeAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): number { + let best = -1; + let bestDistance = hitPx; + points.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; +} + +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. + * + * **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라 + * **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한 + * 곡선을 가리킨다. */ +export function handleAtScreen( + curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, + picked: number, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; + let bestDistance = hitPx; + curves.forEach((curve) => { + if (curve.node_first !== picked) return; + (["start", "end"] as const).forEach((which) => { + const point = which === "start" ? curve.start : curve.end; + const [x, y] = toScreen([point[0], point[1]]); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = { node: curve.node_first, end: which }; + } + }); + }); + return best; +} + +/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */ +export function segmentAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + maxPx: number, +): number { + let best = -1; + let bestDistance = maxPx; + for (let index = 0; index < points.length - 1; index += 1) { + const [ax, ay] = toScreen(points[index]); + const [bx, by] = toScreen(points[index + 1]); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + } + return best; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts new file mode 100644 index 00000000..f6538512 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Label.ts + * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * + * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 + * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. + * + * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 + * 골라도 입력칸이 잘리지 않아야 한다. + * ========================================================================== */ + +/** 라벨과 노드 사이 여백(px). */ +const GAP_PX = 14; +/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ +const EDGE_PX = 8; + +export interface CurveLabelState { + /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ + seat: number; + /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + at: [number, number]; + /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ + canvas: { width: number; height: number }; + curveOn: boolean; + /** 지금 보일 반지름(m). 곡선이 없으면 null. */ + radiusShown: number | null; + /** 사용자가 못박은 값인지(아니면 자동). */ + forced: boolean; + innerAngleDeg: number | null; + minRadiusM: number; +} + +export interface CurveLabelHandlers { + /** R 칸을 고쳤다 — null 이면 「자동」. */ + onRadius: (value: number | null) => void; + /** 곡선을 지우거나 넣었다. */ + onCurveOn: (on: boolean) => void; + /** 반지름을 자동으로 되돌렸다. */ + onAuto: () => void; +} + +export interface CurveLabel { + show: (state: CurveLabelState) => void; + hide: () => void; +} + +/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */ +export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel { + const root = document.createElement("div"); + root.className = "b05-routeedit__label"; + root.hidden = true; + root.innerHTML = ` +
+ + +
+ + `; + host.append(root); + + const seatText = root.querySelector(".b05-routeedit__curve-label")!; + const toggle = root.querySelector('[data-act="curve-toggle"]')!; + const radius = root.querySelector(".b05-routeedit__curve-radius")!; + const auto = root.querySelector('[data-act="curve-auto"]')!; + const info = root.querySelector(".b05-routeedit__curve-info")!; + + // 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + root.addEventListener("pointerdown", (event) => event.stopPropagation()); + root.addEventListener("dblclick", (event) => event.stopPropagation()); + root.addEventListener("contextmenu", (event) => event.stopPropagation()); + + let curveOn = true; + radius.addEventListener("change", () => { + const value = Number(radius.value); + handlers.onRadius(Number.isFinite(value) && value > 0 ? value : null); + }); + toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + auto.addEventListener("click", () => handlers.onAuto()); + + function place(at: [number, number], canvas: { width: number; height: number }): void { + // 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다. + const width = root.offsetWidth; + const height = root.offsetHeight; + // 기본은 오른쪽. 오른쪽이 좁으면 왼쪽으로 접는다(사용자 말대로 「우측에」가 기본). + let left = at[0] + GAP_PX; + if (left + width > canvas.width - EDGE_PX) left = at[0] - GAP_PX - width; + left = Math.max(EDGE_PX, Math.min(left, canvas.width - width - EDGE_PX)); + let top = at[1] - height / 2; + top = Math.max(EDGE_PX, Math.min(top, canvas.height - height - EDGE_PX)); + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + } + + return { + show(state) { + curveOn = state.curveOn; + root.hidden = false; + seatText.textContent = `${state.seat + 1}번째 꺾임점`; + toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; + radius.disabled = !state.curveOn; + auto.disabled = !state.curveOn || !state.forced; + radius.value = + state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); + const inner = state.innerAngleDeg; + info.textContent = state.curveOn + ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${state.minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + place(state.at, state.canvas); + }, + hide() { + root.hidden = true; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 9933d6fb..7e6bd7d0 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -131,21 +131,47 @@ text-align: center; } -/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). - 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ -.b05-routeedit__curve { - /* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고 - 이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */ - flex: none; - position: relative; - z-index: 1; +/* R 라벨 — 고른 꺾임점 **옆에 떠 있는** 작은 상자(2026-09-07 사용자 지시 ③). + 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. + 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ +.b05-routeedit__label { + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 11rem; + padding: var(--spacing-8, 8px); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-6, 6px); + background: var(--color-surface-2, #f9fafb); + box-shadow: 0 4px 14px rgb(0 0 0 / 25%); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__label-head { display: flex; align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border-top: 1px solid var(--color-border, #e5e7eb); - background: var(--color-surface-2, #f9fafb); - font-size: var(--font-size-13, 13px); + justify-content: space-between; + gap: var(--spacing-8, 8px); +} + +.b05-routeedit__label-toggle, +.b05-routeedit__label-auto { + padding: 1px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + background: var(--color-surface, #fff); + color: inherit; + font: inherit; + font-size: var(--font-size-12, 12px); + cursor: pointer; +} + +.b05-routeedit__label-toggle:disabled, +.b05-routeedit__label-auto:disabled { + color: var(--color-text-muted, #9ca3af); + cursor: default; } .b05-routeedit__curve-label { @@ -161,7 +187,7 @@ } .b05-routeedit__curve-radius { - width: 5.5rem; + width: 4.5rem; padding: 2px 6px; border: 1px solid var(--color-border, #e5e7eb); border-radius: var(--radius-4, 4px); @@ -175,9 +201,7 @@ } .b05-routeedit__curve-info { - flex: 1 1 auto; + max-width: 16rem; color: var(--color-text-muted, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.35; } -- 2.54.0 From 4300be838510f432285f13adab8bb7fd31bcbd8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:28:02 +0900 Subject: [PATCH 292/441] =?UTF-8?q?fix(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=A0=91=EC=84=A0=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?=EB=8A=98=20=EB=B3=B4=EC=9D=B4=EA=B2=8C,=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EC=9D=84=20R+=EA=B3=A1=EC=84=A0=20=EA=B8=B8=EC=9D=B4=EB=A1=9C,?= =?UTF-8?q?=20=EC=A4=91=EC=8B=AC=20=EB=B0=98=EB=8C=80=EC=AA=BD=EC=97=90=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 3건 반영. - **접선점 표기 복구** — 손잡이를 「고른 곡선만」으로 줄이면서 직선↔R 만나는 자리의 **표기까지 없앴음**. 접선점은 손잡이이기 이전에 읽을 정보라 **늘 그림**(고른 곡선은 속을 채워 도드라지게). 노드를 못 집던 문제는 집기 우선순위로 이미 풀려 겹치지 않음. - **라벨을 반지름 + 곡선 길이 두 칸으로, [자동] 단추 삭제** — 교각 Δ 는 앞뒤 직선이 정하므로 L = R·Δ 로 묶임. 길이를 받으면 R 로 바꿔 한 값만 보관. 칸을 비우면 자동. - **라벨 자리를 곡선 중심의 반대쪽으로**(상하좌우) — 중심 쪽에 두면 곡선을 가림. 중심 방향은 접선점 두 방향 단위벡터의 합(각 이등분선)으로 구함. - ⚠ 라벨 글자가 안 읽히던 것 — `--color-surface-2` 가 이 테마에 없어 밝은 기본값으로 떨어졌음. 모달 본체와 같은 토큰으로 교체(실측 배경 rgb(37,31,56)·글자 rgb(228,224,240)). - 상자 높이가 늦게 자라 자리가 11px 어긋나던 것도 다음 프레임에 다시 맞추게 함. - 700줄 유지 — 교각·중심방향은 `_Label`, 등고선 띠는 `_Input` 으로 옮겨 본체 687줄. 실화면 검증 — 접선점이 고르기 전에도 보임 / 곡선 길이 40 → 반지름 203.7m 로 따라옴 / 라벨이 중심 반대쪽(위)에 붙음 / [자동] 없음. 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 92 +++++++------- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 42 ++++++- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 112 ++++++++++++++---- .../B05_Profile_UI_Style_RouteEdit.css | 56 +++++---- 4 files changed, 194 insertions(+), 108 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index c54db379..09a1f611 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -32,11 +32,16 @@ import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import { bindRouteEditNavigation, + contourBandRect, handleAtScreen, nodeAtScreen, segmentAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; -import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, +} from "./B05_Profile_UI_RouteEdit_Label"; import { bindHistoryControls, createRouteEditHistory, @@ -199,34 +204,6 @@ export async function openRouteEditModal( context.restore(); } - /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. - * - * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 - * 아직 없으면 null(전체를 그린다). */ - function contourBandRect(): { x: number; y: number; width: number; height: number } | null { - const line = plannedLine.length ? plannedLine : planned; - if (!meta || line.length < 2) return null; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of line) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. - const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); - const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); - return { - x: Math.min(left, right), - y: Math.min(top, bottom), - width: Math.abs(right - left), - height: Math.abs(bottom - top), - }; - } - function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -237,7 +214,9 @@ export async function openRouteEditModal( context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = contourBandRect(); + const band = meta + ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) + : null; if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); @@ -293,21 +272,21 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { - // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 - // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. - if (curve.node_first !== picked) return; + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. + if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === picked; [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); - context.rect( - x - CURVE_HANDLE_PX, - y - CURVE_HANDLE_PX, - CURVE_HANDLE_PX * 2, - CURVE_HANDLE_PX * 2, - ); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); @@ -320,7 +299,7 @@ export async function openRouteEditModal( /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + handleAtScreen(curveInfo, toScreen, px, py, NODE_HIT_PX + 2); const nodeAt = (px: number, py: number): number => nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); const segmentAt = (px: number, py: number): number => @@ -374,7 +353,7 @@ export async function openRouteEditModal( ); } - // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── const curveLabelBox = createCurveLabel(canvas.parentElement!, { onRadius: (value) => { if (picked < 0) return; @@ -382,16 +361,19 @@ export async function openRouteEditModal( // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); }, + onArcLength: (value) => { + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); + }, onCurveOn: (on) => { if (picked < 0) return; curveOn[picked] = on; applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); }, - onAuto: () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - applyEdit("반지름을 자동으로 되돌렸습니다."); - }, }); /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ @@ -401,13 +383,23 @@ export async function openRouteEditModal( return; } const forced = curveRadius[picked]; + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = forced ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); curveLabelBox.show({ seat: picked, at: toScreen(planned[picked]), + centerDirection: pickedCurve + ? centerDirectionOf( + toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + toScreen(pickedCurve.start), + toScreen(pickedCurve.end), + ) + : null, canvas: { width: view.width, height: view.height }, curveOn: curveOn[picked] !== false, - radiusShown: - forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, forced: forced !== null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, minRadiusM, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index a73dd52e..89555c43 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -126,14 +126,14 @@ export function nodeAtScreen( return best; } -/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null. * - * **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라 - * **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한 - * 곡선을 가리킨다. */ + * 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는 + * 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07). + * 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 + * 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */ export function handleAtScreen( curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, - picked: number, toScreen: ScreenOf, px: number, py: number, @@ -142,7 +142,6 @@ export function handleAtScreen( let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = hitPx; curves.forEach((curve) => { - if (curve.node_first !== picked) return; (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); @@ -181,3 +180,34 @@ export function segmentAtScreen( } return best; } + +/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 + * 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */ +export function contourBandRect( + line: Array<[number, number]>, + toScreen: ScreenOf, + bandM: number, +): { x: number; y: number; width: number; height: number } | null { + if (line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - bandM, minY - bandM]); + const [right, top] = toScreen([maxX + bandM, maxY + bandM]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index f6538512..9096e81b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -1,15 +1,47 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts - * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. * * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. - * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. * - * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 - * 골라도 입력칸이 잘리지 않아야 한다. + * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 + * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 + * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * + * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 + * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. * ========================================================================== */ +/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ +export function deflectionRad(innerAngleDeg: number | null | undefined): number { + if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; + return ((180 - innerAngleDeg) * Math.PI) / 180; +} + +/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다. + * + * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 + * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ +export function centerDirectionOf( + apex: [number, number], + start: [number, number], + end: [number, number], +): [number, number] | null { + const arm = (point: [number, number]): [number, number] => { + const dx = point[0] - apex[0]; + const dy = point[1] - apex[1]; + const length = Math.hypot(dx, dy); + return length <= 1e-9 ? [0, 0] : [dx / length, dy / length]; + }; + const [ux, uy] = arm(start); + const [vx, vy] = arm(end); + const sx = ux + vx; + const sy = uy + vy; + const length = Math.hypot(sx, sy); + return length <= 1e-9 ? null : [sx / length, sy / length]; +} + /** 라벨과 노드 사이 여백(px). */ const GAP_PX = 14; /** 캔버스 가장자리에서 이만큼은 띄운다(px). */ @@ -20,11 +52,16 @@ export interface CurveLabelState { seat: number; /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ at: [number, number]; + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. + * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + centerDirection: [number, number] | null; /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ canvas: { width: number; height: number }; curveOn: boolean; /** 지금 보일 반지름(m). 곡선이 없으면 null. */ radiusShown: number | null; + /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + arcLengthShown: number | null; /** 사용자가 못박은 값인지(아니면 자동). */ forced: boolean; innerAngleDeg: number | null; @@ -32,12 +69,12 @@ export interface CurveLabelState { } export interface CurveLabelHandlers { - /** R 칸을 고쳤다 — null 이면 「자동」. */ + /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ onRadius: (value: number | null) => void; + /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ + onArcLength: (value: number | null) => void; /** 곡선을 지우거나 넣었다. */ onCurveOn: (on: boolean) => void; - /** 반지름을 자동으로 되돌렸다. */ - onAuto: () => void; } export interface CurveLabel { @@ -55,10 +92,13 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
-
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¥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 Sp4k}ikG8s=T9t}r$o~P>2rMxG literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p64_1.png new file mode 100644 index 0000000000000000000000000000000000000000..73c309b9f94a3c213cfe82cf825704ef56dc7c2d GIT binary patch literal 25601 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 XmI*-wquIeXJ+9o=xT9W=w2%HDCj++> literal 0 HcmV?d00001 diff --git a/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png b/resources/knowledge/original/원가계산/건설공사_표준품셈/pic/2026년_건설공사_표준품셈_개정사항_p98_1.png new file mode 100644 index 0000000000000000000000000000000000000000..e88ed6066585ff8c7b9c9edd88cde231c26df08b GIT binary patch literal 23691 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 literal 0 HcmV?d00001 -- 2.54.0 From e9a25bbf875b3809f140a8a8d02de754852ba378 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:28:42 +0900 Subject: [PATCH 426/441] =?UTF-8?q?feat(B08):=20=EB=8F=8C=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=B6=95=EC=9D=84=20=EC=9D=B4=EC=9D=8C=20=E2=80=94?= =?UTF-8?q?=20=EA=B3=84=EC=88=98=20=EC=85=8B=EC=9D=B4=20=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=95=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랩탑이 stone_kind 칸을 만들어 축이 생김. 지금까지 계수 셋이 축 없이 한 벌(깬돌 계열)로만 돌고 있었음. 원문 근거 셋을 데이터 파일로 뺌 (resources/data_masonry/stone_kind_*) 고임돌 품셈 13-4-3 · 돌 종류 네 줄(야면석/깬잡석/깬돌/견치돌) 채움콘크리트 품셈 13-4-4 [주]① · 두 줄(야면석 33.3% / 깬돌 계열 45%) 뒤채움 교본 7-3 · 깬돌·잡석 1/2 · 야면석 1/3 ⚠ 안 고르면 종전 값 그대로 — 건설품셈 참고자료 한 벌로 돌되 그 사실을 알림. 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빔. (실측: 미지정 고임돌 3.915 · 채움콘 5.220 · 막자갈 15.130 — 고치기 전과 같음) ⚠ 만들다 잡은 것 — 표는 「뒤채움 몫」인데 우리 식은 「빼는 몫」이라 뜻이 반대. 그대로 넣었더니 미지정 막자갈이 15.130 → 19.045 로 바뀌었음. 1 에서 빼게 고침. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었음. ⚠ 지어내지 않는 자리 — 원문 「-」(견치돌 25·30 · 야면석 75)는 null 로 두고 사유를 냄. 모르는 종류도 조용히 안 넘김. 시험 7건 — 미지정이 종전 그대로 · 야면석이 바뀜 · 깬돌이 종전과 같음 · ⚠ 뒤채움 몫을 뒤집어 쓰지 않음 · 「-」를 안 지어냄 · 모르는 종류 · 근거 문구 시험: 720 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 93 ++++++++++++++++++- .../data_masonry/stone_kind_2026-01-01.json | 69 ++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 resources/data_masonry/stone_kind_2026-01-01.json diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index ed3dcd3c..16edc900 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -30,7 +30,10 @@ from __future__ import annotations +import json import math +from pathlib import Path +from functools import lru_cache from dataclasses import dataclass, field from typing import Any, Iterable @@ -57,6 +60,66 @@ STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { } DEFAULT_BACK_LENGTH_CM = 45 + +#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. +#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. +#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 +#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. +STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +STONE_KIND_PREFIX = "stone_kind_" +STONE_KIND_OPTION = "stone_kind" + + +@lru_cache(maxsize=1) +def load_stone_kind_table() -> dict[str, Any]: + """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" + folder = STONE_KIND_DIR + if not folder.is_dir(): + return {} + files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) + if not files: + return {} + try: + return json.loads(files[-1].read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: + """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. + + ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. + ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. + """ + table = load_stone_kind_table() + key = str(back_cm) + kind = str(options.get(STONE_KIND_OPTION) or "").strip() + if not table: + return {}, "" + if not kind: + fallback = table.get("fallback") or {} + return { + "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), + "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), + "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), + "kind": "", + }, str(fallback.get("message") or "") + if kind not in (table.get("kinds") or []): + return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" + wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) + fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) + ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) + note = "" + if wedge is None: + note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" + return { + "wedge_stone_m3_per_m2": wedge, + "fill_concrete_m3_per_m2": fill, + "backfill_ratio": ratio, + "kind": kind, + }, note + + # 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. STONE_MASONRY = { # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). @@ -304,7 +367,23 @@ def stone_masonry( return [], ["높이·연장이 없어 전개하지 않음"] back_cm = _back_length(options) - table = STONE_BACK_LENGTH_TABLE[back_cm] + table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) + # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). + # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 + # 멈추면 이미 저장된 프로젝트가 통째로 빈다. + picked, kind_note = stone_coefficients(options, back_cm) + if kind_note: + notes.append(kind_note) + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + if picked.get(key) is not None: + table[key] = picked[key] + #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. + #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 + #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. + #: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음). + backfill_share = picked.get("backfill_ratio") + body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0 + kind_label = str(picked.get("kind") or "") # ⚠ `face_slope_ratio` 는 **레지스트리에 없는 키**다 — 즉 지금은 늘 기본 0.3 으로 돈다. # 상수로 두는 것이 아니라 「칸이 생기면 바로 받는다」는 뜻으로 남겨 둔다. # (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것 — 이쪽은 **칸 자체가 없다**.) @@ -356,13 +435,19 @@ def stone_masonry( ) ) - # 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로. + # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). + # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) - rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge) + rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge) if rubble > 0: components.append( Component( - "막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)" + "막자갈", + "㎥", + rubble, + DESTINATION["막자갈"], + f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌)" + + (f" · {kind_label}" if kind_label else ""), ) ) diff --git a/resources/data_masonry/stone_kind_2026-01-01.json b/resources/data_masonry/stone_kind_2026-01-01.json new file mode 100644 index 00000000..13611713 --- /dev/null +++ b/resources/data_masonry/stone_kind_2026-01-01.json @@ -0,0 +1,69 @@ +{ + "schema_version": "1.0", + "dataset_id": "stone_kind_unit", + "effective_date": "2026-01-01", + "note": "돌쌓기 계수는 **돌 종류로 갈린다**. 지금까지 한 벌(깬돌 계열)로만 돌고 있었다 — 2026-09-08 지식DB 대조에서 드러났다.", + "why": "품셈 13-4-3(고임돌)은 돌 종류로 **네 줄**, 13-4-4 [주]①(채움 콘크리트)은 **두 줄**, 교본 7-3 뒤채움은 **두 값**이다. 세 곳이 같은 축인데 우리는 축 없이 하나로 돌고 있었다.", + "option_key": "stone_kind", + "option_note": "레지스트리 `masonry_wet`·`masonry_dry` 의 옵션. 2026-09-08 랩탑 창이 만들었고 키 이름을 두 창이 미리 맞췄다(`back_len_cm` 이름 어긋남 사고를 되풀이하지 않으려고).", + "sources": { + "forest_13_4_3": { + "doc": "산림사업 표준품셈 13-4-3 고임돌 소요량 (단위: ㎥/㎡당)", + "note": "돌 종류 네 줄. 「-」는 그 규격에 그 돌을 안 쓴다는 뜻이라 지어내지 않는다." + }, + "forest_13_4_4_note1": { + "doc": "산림사업 표준품셈 13-4-4 [주]① 찰쌓기 및 찰붙임의 채움 콘크리트 소요량 (㎥/㎡당)", + "note": "두 줄뿐 — 야면석·호박돌(뒷길이의 33.3 %) / 깬잡석·깬돌·견치돌(45 %)." + }, + "textbook_7_3": { + "doc": "임도기술교본 7장 3절 (지식DB 돌쌓기.md §4 · 흙막이.md §4)", + "quote": "뒤채움 = 돌쌓기 표면적 × 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)" + }, + "construction_reference": { + "doc": "건설공사 표준품셈 [참고자료] 돌쌓기 규격별 소요량", + "note": "⚠ **돌 종류로 안 갈리는 한 벌**이고, 그 값이 우리가 지금까지 쓰던 값이다(고임돌 = 깬돌 줄 · 채움 콘크리트 0.16/0.20/0.25/0.27). 2026 개정안이 이 참고자료를 **「삭제 검토」** 로 두었다." + } + }, + "policy": { + "primary": "산림사업이므로 **산림품셈이 1차 적용**(CLAUDE.md 3장). 건설품셈 값은 미지정일 때의 기본값 근거로만 쓴다.", + "unset_is_flagged": "돌 종류를 안 고르면 **건설품셈 참고자료 한 벌**로 돌되 그 사실을 알린다 — 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.", + "no_interpolation": "표에 없는 뒷길이·「-」 칸은 **지어내지 않는다.**" + }, + "kinds": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "wedge_stone_m3_per_m2": { + "note": "고임돌 — 품셈 13-4-3. 키는 뒷길이(㎝). `null` 은 원문 「-」.", + "야면석·호박돌": { "25": 0.06, "30": 0.07, "35": 0.09, "45": 0.11, "55": 0.14, "60": 0.15, "75": null }, + "깬잡석": { "25": 0.09, "30": 0.11, "35": 0.13, "45": 0.16, "55": 0.19, "60": 0.21, "75": 0.26 }, + "깬돌": { "25": null, "30": 0.10, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 }, + "견치돌": { "25": null, "30": null, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 } + }, + "fill_concrete_m3_per_m2": { + "note": "채움 콘크리트 — 품셈 13-4-4 [주]①. **두 줄뿐**이라 견치돌·깬잡석·깬돌이 한 값을 쓴다.", + "야면석·호박돌": { "25": 0.08, "30": 0.10, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 }, + "깬잡석": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 }, + "깬돌": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 }, + "견치돌": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 } + }, + "backfill_ratio_of_back_length": { + "note": "뒤채움이 뒷길이에서 차지하는 몫 — 교본 7-3. 나머지가 잡석(막자갈)이 된다.", + "야면석·호박돌": 0.3333333333333333, + "깬잡석": 0.5, + "깬돌": 0.5, + "견치돌": 0.5 + }, + "fallback": { + "note": "돌 종류 미지정일 때 — 건설품셈 참고자료 한 벌. **지금까지 쓰던 값 그대로**라 저장된 프로젝트의 값이 안 바뀐다.", + "wedge_stone_m3_per_m2": { "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20 }, + "fill_concrete_m3_per_m2": { "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27 }, + "backfill_ratio_of_back_length": 0.3333333333333333, + "backfill_note": "⚠ 이 값은 **뒤채움이 차지하는 몫**이다. 우리 막자갈 식은 입적에서 **돌 몸통**을 빼므로 코드가 `1 − 이 값` 을 쓴다. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었다.", + "message": "돌 종류를 안 정해 건설품셈 참고자료 값으로 섰습니다 — 구조물 상세 입력에서 고르면 산림품셈 계수로 바뀝니다" + }, + "not_here": { + "note": "이 표가 정하지 않는 것.", + "items": [ + "돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.", + "전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다." + ] + } +} -- 2.54.0 From 3a452028d3b9e8eb71b9d4cfca4135b4a1296d5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:33:23 +0900 Subject: [PATCH 427/441] =?UTF-8?q?feat(B09):=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EC=9E=90=EB=A6=BF=EC=88=98=EB=A5=BC=20=ED=92=88=EC=85=88=201-2?= =?UTF-8?q?-2=20=EC=A2=85=EB=AA=A9=EB=B3=84=EB=A1=9C,=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=EB=8A=94=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=EC=9D=84=20=EC=95=88=20=EB=A7=8C=EB=93=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수량 자릿수 (품셈 1-2-2의 1) - 화면이 모든 수량을 2자리로 찍고 있었음. 품셈은 종목별로 자리를 따로 정함 — 체적합계·시멘트·철근 정수 / 돌쌓기·옹벽·떼 1자리 / 철강재 3자리. - 수량은 반올림([주]①), 금액은 버림 — 반대라 모듈을 갈라 둠. - 항목이 표를 이김([주]②) → override 받음. 표에 없는 종목은 안 건드림. - 서버가 줄마다 quantity_digits 를 실어 보내고 화면은 그 자리로 찍기만 함. 금액 자리 보강 (품셈 1-2-2의 2) - 설계서 총액 1,000원 미만 버림 · 일위대가 계금 1원 미만 버림 추가. 금액란(0.1원)·소계(1원)와 자리가 넷 다 다름. ⚠ 단위 불일치 가드 - 돌쌓기(찰)이 연장 10m 로 오는데 일위대가는 ㎡당이라 52,938.9×10 = 529,389원이 조용히 서 있었음. 면적으로 세면 1,381,753원 — 2.6배 적은 금액이었음. - 단위가 다르면 곱하지 않고 사유를 남김. 표기 차이(㎥/m3·개소/개)는 흡수하되 환산은 안 함. 내역 합계 6,252,172 → 5,048,775 (틀린 두 줄이 빠짐). 제잡비 밑수 - 닫힌 물음임을 원문 인용으로 못 박음 — 산림품셈 [주]③ 여섯 자리가 「노무비의 합계액」이고, 일위대가 안에는 간접노무비가 없으므로 그 표의 노무비 합 = 직접노무비. 하루에 두 번 뒤집힌 값이라 세 번째가 없게 근거를 남김. 시험 10건 추가, 238건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 12 ++ .../B09_Estimation_BillOfQuantities_Rows.py | 55 +++++++++ B09_Estimation/B09_Estimation_PriceBook.py | 17 ++- .../B09_Estimation_QuantityDigits.py | 104 ++++++++++++++++++ B09_Estimation/B09_Estimation_Rounding.py | 15 ++- B09_Estimation/B09_Estimation_UI_Page.ts | 25 +++-- 6 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_QuantityDigits.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 96a30ce6..fd50b7fc 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -32,6 +32,7 @@ from B09_Estimation.B09_Estimation_Guards import ( check_haul_volume_within_cut, ) from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master +from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import ( UnitPriceBuild, @@ -157,6 +158,13 @@ class BillRow: def money(value: Decimal | None) -> str | None: return None if value is None else str(value) + # 수량 소수자리는 **종목마다 다르다** (품셈 1-2-2). 값은 전정밀로 두고 **찍을 자리만** + # 함께 보낸다 — 자르는 것은 표를 그리는 쪽 몫이다(단수 규칙과 같은 원칙). + shown, digits = ( + (None, None) + if self.quantity is None + else round_quantity(self.quantity, self.name, self.unit, self.spec) + ) return { "item_no": self.item_no, "level": self.level, @@ -165,6 +173,10 @@ class BillRow: "spec": self.spec, "unit": self.unit, "quantity": money(self.quantity), + #: 품셈 1-2-2 종목별 자리로 **반올림한** 표시값. 자리를 모르면 `None` 이고, + #: 그때 화면은 종전대로 찍는다(지어내지 않는다). + "quantity_shown": money(shown) if digits is not None else None, + "quantity_digits": digits, "unit_price_krw": money(self.unit_price_krw), "amount_krw": money(self.amount_krw), "material_krw": str(self.material_krw), diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index f3544b42..fb2d04fe 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -277,6 +277,29 @@ def _leaf_row( if part ) + if title.unit and row.unit and not _same_unit(title.unit, row.unit): + # ⚠⚠ **단위가 다르면 곱하지 않는다** (2026-09-08 실측으로 드러난 자리). + # 돌쌓기(찰)이 B08 에서 **연장 10 m** 로 오는데 품셈 일위대가는 **㎡당**이라, + # 52,938.9원/㎡ × 10 m = 529,389원이 조용히 서 있었다. 면적으로 세면 26.101㎡ × + # 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다. + # 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음 + # 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.** + row.note = ( + f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. " + "곱하면 금액이 틀리므로 비워 둡니다." + ) + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": f"단위 불일치 — 수량 {row.unit} vs 단가 {title.unit}당", + "blocked_kind": "unit_mismatch", + } + ) + return row + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. if price_code not in result.used_unit_prices: result.used_unit_prices.append(price_code) @@ -340,3 +363,35 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: } ) return row + + +#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다. +_UNIT_ALIASES = { + "㎥": "m3", + "m³": "m3", + "M3": "m3", + "루베": "m3", + "㎡": "m2", + "m²": "m2", + "M2": "m2", + "㎏": "kg", + "KG": "kg", + "톤": "ton", + "TON": "ton", + "t": "ton", + "개소": "개", + "EA": "개", + "ea": "개", + "인": "인", + "인/일": "인", +} + + +def _same_unit(left: str, right: str) -> bool: + """두 단위가 같은가. **표기 차이만 흡수하고, 환산은 하지 않는다** — m 과 ㎡ 는 다르다.""" + + def key(text: str) -> str: + tight = "".join(str(text or "").split()) + return _UNIT_ALIASES.get(tight, tight) + + return key(left) == key(right) diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index ad91cc60..fa09427a 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -215,8 +215,21 @@ class PriceBook: # 명시하는 서식인데 13-6-2 는 그냥 「노무비」다. # 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람 # 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다. - # ⚠ 잠정 — 조종원 노임을 넣으면 찰쌓기 60~80 기준 2,098.46 → 2,565.90 - # (약 +22 %). 사용자 확정 대기(PLAN 9-6). + # + # ⚠⚠ **닫힌 물음이다 — 다시 뒤집지 말 것** (2026-09-08 전수 대조로 확정). + # 이 값은 하루에 두 번 뒤집혔고 금액이 약 ±22 % 움직였다. 세 번째가 + # 없도록 근거를 여기 못 박는다. + # ㉠ **산림품셈이 1차 적용**이고(품셈 1-1·별표2, 건설품셈은 보완), + # 그 [주]③ 은 여섯 자리에서 한결같이 **「노무비의 합계액」**이라 적는다 + # (원문 L5498·L7013·L7299·L7323·L7350·L7401). + # ㉡ **일위대가 안에는 간접노무비가 없다.** 간접노무비는 원가계산서 층에서 + # 「직접노무비 × 율」로 나중에 생기는 값이다 + # (resources/knowledge/.../05_원가정보/원가계산_체계.md §2 밑수 정의표). + # ⇒ 그러므로 **그 표의 노무비 줄 합 = 직접노무비**이고, 「직접노무비냐 + # 노무비 계정이냐」라는 물음 자체가 이 층에서는 성립하지 않는다. + # ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로 + # 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다. + # ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다. total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100)) continue diff --git a/B09_Estimation/B09_Estimation_QuantityDigits.py b/B09_Estimation/B09_Estimation_QuantityDigits.py new file mode 100644 index 00000000..0141c6a1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_QuantityDigits.py @@ -0,0 +1,104 @@ +"""B09 원가계산 — **수량의 소수자리**는 종목마다 다르다 (산림품셈 1-2-2의 1). + +**왜 있는가** — 지금까지 화면이 **모든 수량을 2자리로** 찍고 있었는데, 품셈은 종목별로 +자리를 따로 정해 두었다. 체적합계·시멘트·철근은 **정수**, 돌쌓기·옹벽·떼는 **1자리**, +철강재는 **3자리**다. 2자리 고정은 그 어느 것도 아니다 (2026-09-08 지식DB 대조에서 드러남). + + 원문 = resources/knowledge/original/행정규칙/임도 품셈 적용기준 + (현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md L575~660 + 요약 = resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md §2 + +⚠ **수량은 반올림이다** ([주]① 「본 표에 따르며, **반올림**하여 적용한다」). +금액이 전부 **버림**인 것과 반대다 — 한 모듈에 섞어 두면 반드시 헷갈리므로 +금액은 `B09_Estimation_Rounding` 이 따로 맡는다. + +⚠ **항목이 표를 이긴다** ([주]② 「품셈 각 항목에서 제시한 소숫자리가 본 표와 상이할 경우 +**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종 +마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다. + +⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만 +여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 — +「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다. +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal + +#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다. +#: ⚠ 값을 고칠 일이 생기면 원문을 먼저 볼 것 — 여기서 고치면 근거가 끊긴다. +_DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = ( + # 종목 이름 후보 · 단위 후보 · 소수자리 + (("공사연장", "노선연장"), ("m",), 0), + (("공사폭원", "노폭"), ("m",), 1), + (("직공인부",), ("인",), 2), + (("체적합계", "토적합계"), ("㎥", "m3"), 0), + (("떼", "평떼", "줄떼"), ("㎡", "m2"), 1), + (("모래", "자갈", "조약돌"), ("㎥", "m3"), 2), + (("견치돌", "깬돌"), ("㎡", "m2"), 1), + (("야면석",), ("㎥", "m3"), 1), + (("돌쌓기", "돌붙임"), ("㎥", "m3", "㎡", "m2"), 1), + (("사석",), ("㎥", "m3"), 1), + (("다듬돌", "절석", "판석"), ("개",), 2), + (("벽돌", "블록"), ("개",), 0), + (("시멘트",), ("kg", "㎏"), 0), + (("모르타르", "모르터"), ("kg", "㎏"), 2), + (("콘크리트",), ("㎥", "m3"), 2), + (("합판",), ("장",), 1), + (("말뚝",), ("개",), 0), + (("철강재", "강재"), ("kg", "㎏"), 3), + (("용접봉",), ("kg", "㎏"), 1), + (("철근",), ("kg", "㎏"), 0), + (("볼트", "너트", "꺽쇠"), ("개",), 0), + (("철선", "철사"), ("kg", "㎏"), 2), + (("못",), ("kg", "㎏"), 2), + (("화약",), ("kg", "㎏"), 3), + (("뇌관",), ("개",), 0), + (("도화선",), ("m",), 1), + (("수로연장",), ("m",), 1), + (("옹벽",), ("㎡", "m2"), 1), + (("도장", "칠하기"), ("㎡", "m2"), 1), + (("방수",), ("㎡", "m2"), 1), + (("보오링", "보링"), ("m",), 1), +) + +#: 위 표에 이름이 안 걸릴 때 **단위만으로** 줄 수 있는 자리 — 품셈 「토적」 줄이 근거다. +#: 토적: 높이·너비 m 2 · 단면적 ㎡ 1 · 체적 ㎥ 2. +#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라 +#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다. +_UNIT_ONLY = {"㎥": 2, "m3": 2} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def digits_for(name: str, unit: str, spec: str = "") -> int | None: + """그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.""" + haystack = _tight(name) + _tight(spec) + unit_tight = _tight(unit) + for words, units, digits in _DIGITS: + if unit_tight not in units: + continue + if any(word in haystack for word in words): + return digits + return _UNIT_ONLY.get(unit_tight) + + +def round_quantity( + value: Decimal, + name: str, + unit: str, + spec: str = "", + override: int | None = None, +) -> tuple[Decimal, int | None]: + """수량을 그 종목의 자리로 **반올림**한다. + + 돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`. + `override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다. + """ + digits = override if override is not None else digits_for(name, unit, spec) + if digits is None: + return value, None + quantum = Decimal(1).scaleb(-digits) + return value.quantize(quantum, rounding=ROUND_HALF_UP), digits diff --git a/B09_Estimation/B09_Estimation_Rounding.py b/B09_Estimation/B09_Estimation_Rounding.py index 5a939ebe..e56eaf3e 100644 --- a/B09_Estimation/B09_Estimation_Rounding.py +++ b/B09_Estimation/B09_Estimation_Rounding.py @@ -38,6 +38,12 @@ class OutputPlace(str, Enum): OWNER_MATERIAL_TOTAL = "owner_material_total" #: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」) UNIT_PRICE_ROW = "unit_price_row" + #: 일위대가표의 **계금** — 1원 미만 버림. ⚠ 금액란(0.1원)과 **자리가 다르다** + #: (품셈 1-2-2의 2 표에 두 줄이 따로 있다). + UNIT_PRICE_TOTAL = "unit_price_total" + #: **설계서의 총액** — 1,000원 미만 버림 (품셈 1-2-2의 2 「설계서의 총액 … 1,000 미만버림」). + #: ⚠ 소계·금액란(1원)과 다르다. 맨 마지막 한 자리에서만 쓴다. + GRAND_TOTAL = "grand_total" def round_at(value: Decimal, place: OutputPlace) -> Decimal: @@ -46,7 +52,14 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal: 자리를 안 대고 부르는 길을 두지 않는다 — 기본값을 두면 어느 자리인지 모른 채 아무 함수나 쓰게 된다. """ - if place in (OutputPlace.BOQ_ROW, OutputPlace.OVERHEAD_ROW, OutputPlace.PROCUREMENT_FEE): + if place is OutputPlace.GRAND_TOTAL: + return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_FLOOR) * _THOUSAND + if place in ( + OutputPlace.BOQ_ROW, + OutputPlace.OVERHEAD_ROW, + OutputPlace.PROCUREMENT_FEE, + OutputPlace.UNIT_PRICE_TOTAL, + ): return value.quantize(_ONE, rounding=ROUND_FLOOR) if place is OutputPlace.RESOURCE_SUMMARY: return value.quantize(_ONE, rounding=ROUND_HALF_UP) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index abf5fa18..700db411 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -653,6 +653,9 @@ interface BillRowDto { spec: string; unit: string; quantity: string | null; + /** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */ + quantity_shown: string | null; + quantity_digits: number | null; unit_price_krw: string | null; amount_krw: string | null; is_group: boolean; @@ -713,19 +716,24 @@ interface MaterialSheetDto { } /** - * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. + * 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1). + * + * 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다 + * `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다. + * ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을 + * 아는 척 자르지 않는다. * * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. - * 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다. */ -function formatQuantity(value: string | null): string { +function formatQuantity(value: string | null, digits: number | null = null): string { if (value === null || value === "") return ""; const parsed = Number(value); if (!Number.isFinite(parsed)) return value; + const places = digits ?? 2; return parsed.toLocaleString("ko-KR", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: places, + maximumFractionDigits: places, }); } @@ -863,7 +871,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { indent + row.name, row.spec, row.unit, - formatQuantity(row.quantity), + formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits), row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, @@ -902,7 +910,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { note.textContent = `${L("B09_Estimation_Boq_Excluded")}: ` + bill.excluded - .map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`) + .map( + (row) => + `${row.name} ${formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits)}${row.unit}`, + ) .join(", "); body.append(note); } -- 2.54.0 From 48173b8ca454db8bd3d6ce1fd2c85268134c498d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:33:31 +0900 Subject: [PATCH 428/441] =?UTF-8?q?fix(B08):=20=EB=92=B7=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=91=EC=A7=80=20=EC=95=8A=EC=9D=8C=20=E2=80=94?= =?UTF-8?q?=20=EC=9D=BC=EA=B3=B1=20=EA=B7=9C=EA=B2=A9=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조율 창 지시로 KeyError 를 막으러 갔다가 더 나쁜 것을 찾음. 죽지는 않고 있었고 대신 조용히 접히고 있었음. 40㎝ → 45㎝ 계수로 돎 999㎝ → 60㎝ 계수로 돎 25·30 → 35 로, 75 → 60 으로 접힘 ⚠ 접을 까닭이 없었음 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 일곱 규격을 다 줌. 우리 표가 네 칸만 들고 있었을 뿐임. - 표를 일곱 규격으로 채움(건설품셈 참고자료 값, 고임돌은 「깬돌」 열) - 표에 없는 값(40 등)은 접지 않고 「품셈 표에 없어 물량이 서지 않습니다」 - ⚠ 원문 「-」인 칸은 0 줄을 만들지 않음 — 0 은 「없음」과 구별이 안 되고 받는 쪽이 「값이 0 인 자재」로 읽음 - ⚠ 고른 종류의 빈 칸은 빈 칸으로 덮음 — None 이라고 안 덮었더니 「야면석 75㎝」처럼 원문에 「-」인 칸에 종전 값(깬돌 0.25)이 섰음(만들다 잡음) ⚠ 옛 시험이 「덮는 위 칸으로 접는다」를 계약으로 못 박고 있었음 — 오늘 네 번째. 시험 넷 — 접지 않음 · 표에 없으면 물량 안 냄 · 고른 종류의 빈 칸은 줄 없음 · 일곱 규격이 다 섬. 시험: 723 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_UnitQuantity.py | 73 +++++++---- .../data_masonry/stone_kind_2026-01-01.json | 121 ++++++++++++++++-- 2 files changed, 159 insertions(+), 35 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 16edc900..2ba08c1a 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -52,11 +52,17 @@ from common_util.common_util_quantity_spread import spread_by_unit # ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── # 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. # ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. +#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. +#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { + 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, + 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, + 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, } DEFAULT_BACK_LENGTH_CM = 45 @@ -96,6 +102,8 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, kind = str(options.get(STONE_KIND_OPTION) or "").strip() if not table: return {}, "" + if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: + return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") if not kind: fallback = table.get("fallback") or {} return { @@ -218,11 +226,11 @@ BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") def _back_length(options: dict[str, Any]) -> int: - """뒷길이(㎝). 표에 없는 값이면 **가장 가까운 아래 칸**이 아니라 기본으로 간다. + """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. - ⚠ 표는 35·45·55·60 네 칸뿐인데 레지스트리 선택지는 25·30·35·45·55·60·75 일곱이다. - 25·30 은 35 로, 75 는 60 으로 접는다 — **원단위표가 그 구간을 그렇게 덮는다** - (품셈 13-4 뒷길이 표준). 접은 사실은 근거 문구에 적는다. + ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 + 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — + 접으면 다른 규격 계수가 조용히 돈다. """ for key in BACK_LENGTH_KEYS: raw = options.get(key) @@ -232,13 +240,10 @@ def _back_length(options: dict[str, Any]) -> int: value = int(float(raw)) except (TypeError, ValueError): continue - if value in STONE_BACK_LENGTH_TABLE: - return value - # 표에 없는 값 — 그 값을 덮는 **가장 가까운 위 칸**으로 접는다(작은 돌이 큰 칸에 들어감). - larger = [cm for cm in sorted(STONE_BACK_LENGTH_TABLE) if cm >= value] - if larger: - return larger[0] - return max(STONE_BACK_LENGTH_TABLE) + # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 + # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). + # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. + return value return DEFAULT_BACK_LENGTH_CM @@ -367,6 +372,11 @@ def stone_masonry( return [], ["높이·연장이 없어 전개하지 않음"] back_cm = _back_length(options) + if back_cm not in STONE_BACK_LENGTH_TABLE: + # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. + return [], [ + f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" + ] table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 @@ -374,9 +384,15 @@ def stone_masonry( picked, kind_note = stone_coefficients(options, back_cm) if kind_note: notes.append(kind_note) - for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): - if picked.get(key) is not None: - table[key] = picked[key] + # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 + # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). + if picked.get("kind"): + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + table[key] = picked.get(key) + else: + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + if picked.get(key) is not None: + table[key] = picked[key] #: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다. #: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서 #: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다. @@ -411,14 +427,27 @@ def stone_masonry( DESTINATION["돌쌓기"], f"정면적 × √(1+{slope_ratio}²) — 비탈면적", ), - Component( - "고임돌", - "㎥", - masonry_area * _num(table["wedge_stone_m3_per_m2"]), - DESTINATION["고임돌"], - f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)", - ), ] + # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). + # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 + # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. + if table["wedge_stone_m3_per_m2"] is None: + notes.append( + f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + + (f" · {kind_label}" if kind_label else "") + + " 칸이 「-」입니다" + ) + else: + components.append( + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + + (f" · {kind_label}" if kind_label else ""), + ) + ) stone_ton = table["stone_ton_per_m2"] if stone_ton is None: @@ -437,7 +466,7 @@ def stone_masonry( # 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌). # 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3). - wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌 rubble = volume - (masonry_area * (back_cm / 100.0) * body_ratio + wedge) if rubble > 0: components.append( diff --git a/resources/data_masonry/stone_kind_2026-01-01.json b/resources/data_masonry/stone_kind_2026-01-01.json index 13611713..3821fa2a 100644 --- a/resources/data_masonry/stone_kind_2026-01-01.json +++ b/resources/data_masonry/stone_kind_2026-01-01.json @@ -29,20 +29,89 @@ "unset_is_flagged": "돌 종류를 안 고르면 **건설품셈 참고자료 한 벌**로 돌되 그 사실을 알린다 — 값을 못 낸다고 멈추면 이미 저장된 프로젝트가 통째로 빈다.", "no_interpolation": "표에 없는 뒷길이·「-」 칸은 **지어내지 않는다.**" }, - "kinds": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"], + "kinds": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], "wedge_stone_m3_per_m2": { "note": "고임돌 — 품셈 13-4-3. 키는 뒷길이(㎝). `null` 은 원문 「-」.", - "야면석·호박돌": { "25": 0.06, "30": 0.07, "35": 0.09, "45": 0.11, "55": 0.14, "60": 0.15, "75": null }, - "깬잡석": { "25": 0.09, "30": 0.11, "35": 0.13, "45": 0.16, "55": 0.19, "60": 0.21, "75": 0.26 }, - "깬돌": { "25": null, "30": 0.10, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 }, - "견치돌": { "25": null, "30": null, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 } + "야면석·호박돌": { + "25": 0.06, + "30": 0.07, + "35": 0.09, + "45": 0.11, + "55": 0.14, + "60": 0.15, + "75": null + }, + "깬잡석": { + "25": 0.09, + "30": 0.11, + "35": 0.13, + "45": 0.16, + "55": 0.19, + "60": 0.21, + "75": 0.26 + }, + "깬돌": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "견치돌": { + "25": null, + "30": null, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + } }, "fill_concrete_m3_per_m2": { "note": "채움 콘크리트 — 품셈 13-4-4 [주]①. **두 줄뿐**이라 견치돌·깬잡석·깬돌이 한 값을 쓴다.", - "야면석·호박돌": { "25": 0.08, "30": 0.10, "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20, "75": 0.25 }, - "깬잡석": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 }, - "깬돌": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 }, - "견치돌": { "25": 0.11, "30": 0.14, "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27, "75": 0.34 } + "야면석·호박돌": { + "25": 0.08, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "깬잡석": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "깬돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, + "견치돌": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + } }, "backfill_ratio_of_back_length": { "note": "뒤채움이 뒷길이에서 차지하는 몫 — 교본 7-3. 나머지가 잡석(막자갈)이 된다.", @@ -52,9 +121,25 @@ "견치돌": 0.5 }, "fallback": { - "note": "돌 종류 미지정일 때 — 건설품셈 참고자료 한 벌. **지금까지 쓰던 값 그대로**라 저장된 프로젝트의 값이 안 바뀐다.", - "wedge_stone_m3_per_m2": { "35": 0.12, "45": 0.15, "55": 0.18, "60": 0.20 }, - "fill_concrete_m3_per_m2": { "35": 0.16, "45": 0.20, "55": 0.25, "60": 0.27 }, + "note": "돌 종류 미지정일 때 — 건설품셈 [참고자료] 돌쌓기 규격별 소요량 한 벌(고임돌은 「깬돌」 열). ⚠ 일곱 규격을 다 싣는다 — 앞서 네 칸만 들고 있어 25·30·75 를 가까운 칸으로 **접어 올리고** 있었다.", + "wedge_stone_m3_per_m2": { + "25": null, + "30": 0.1, + "35": 0.12, + "45": 0.15, + "55": 0.18, + "60": 0.2, + "75": 0.25 + }, + "fill_concrete_m3_per_m2": { + "25": 0.11, + "30": 0.14, + "35": 0.16, + "45": 0.2, + "55": 0.25, + "60": 0.27, + "75": 0.34 + }, "backfill_ratio_of_back_length": 0.3333333333333333, "backfill_note": "⚠ 이 값은 **뒤채움이 차지하는 몫**이다. 우리 막자갈 식은 입적에서 **돌 몸통**을 빼므로 코드가 `1 − 이 값` 을 쓴다. 종전 하드코딩 2/3 이 곧 야면석(1 − 1/3)이었다.", "message": "돌 종류를 안 정해 건설품셈 참고자료 값으로 섰습니다 — 구조물 상세 입력에서 고르면 산림품셈 계수로 바뀝니다" @@ -65,5 +150,15 @@ "돌중량(ton/㎡) — 우리 값 0.575/0.88/1.10 은 **실무 관측**(울진 라이브러리)이다. 품셈은 「돌의 중량은 형상·종류·부피를 고려하고 건설품셈 1-3-3 재료의 단위중량을 참고하여 계상한다」로만 두어 **표를 안 준다.** 돌 종류 축과 짝이 맞는지 사용자 확정 대기.", "전면면적이 정면적인지 비탈면적인지 — 2026 개정안 [주] 「시공량은 석재의 **전면면적**(㎡)을 기준한다」로 용어만 확인됐다." ] - } + }, + "back_lengths_cm": [ + 25, + 30, + 35, + 45, + 55, + 60, + 75 + ], + "no_folding": "⚠ 표에 없는 뒷길이를 **가까운 칸으로 접지 않는다.** 접으면 40㎝ 가 45㎝ 계수로 조용히 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). 표에 없으면 **계수가 없다고 드러낸다.**" } -- 2.54.0 From 4e59afb0f2c337599a55ed5e2944bb4f2f939e81 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:45:22 +0900 Subject: [PATCH 429/441] =?UTF-8?q?fix(B09):=20=ED=95=9C=20=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EB=B0=91=EC=88=98=EA=B0=80=20=EB=91=98=EC=9D=B8=20?= =?UTF-8?q?=ED=91=9C=EB=A5=BC=20=EB=B0=91=EC=88=98=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EA=B0=88=EB=9D=BC=20=EC=84=B8=EC=9B=80=20=E2=80=94=20=EB=A7=89?= =?UTF-8?q?=EB=8F=8C=20=EC=B1=84=EC=A7=91=203.8=EB=B0=B0=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품셈 13-2 채집 계열은 같은 품을 「㎡당 0.17 / ㎥당 0.64」 두 벌로 줌. 행-자원으로 읽어 앞줄만 잡고 뒷줄을 버려, 막돌 채집이 ㎡당 값을 ㎥ 단위로 달고 있었음 (29,251.5원/㎥ ← 실제 ㎥당은 110,123.5원). - 밑수마다 따로 세우고 갈래 이름에 밑수를 넣음(#㎡당·#㎥당). 갈래의 단위가 그 밑수라 내역서 단위 불일치 검사가 엉뚱한 밑수를 걸러 냄. - 밑수 표기가 하나뿐인 표는 안 건드림 — 넓게 잡으면 멀쩡한 표가 쪼개짐. - 열이 갈래인 표는 갈래 × 밑수로 폄(「45㎝·㎥당」). - 이름을 못 풀면 표째 버림 — 앞줄만 서는 반쪽 단가를 만들지 않음. ⚠ 야면석 채집(13-2-4)은 아직 안 섬 — 직종을 「인 부」로만 적는데 그런 직종이 노임 카탈로그에 없음(품셈 전체에서 이 표 하나뿐). 보통인부로 단정하지 않고 사용자 확정 대기로 둠. 시험 5건 추가, 243건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_ResourceAxis.py | 8 + .../B09_Estimation_ResourceAxis_UnitBasis.py | 151 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py diff --git a/B09_Estimation/B09_Estimation_ResourceAxis.py b/B09_Estimation/B09_Estimation_ResourceAxis.py index bc790a7f..2455f881 100644 --- a/B09_Estimation/B09_Estimation_ResourceAxis.py +++ b/B09_Estimation/B09_Estimation_ResourceAxis.py @@ -379,6 +379,14 @@ def match_table( basis_quantity = None if basis is None else Decimal(str(basis)) unit = table.get("basis_unit") or "" + # ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열). + # 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로 + # 달고 있었다(3.8 배 차이). 형태 판정보다 먼저 가른다. + from B09_Estimation.B09_Estimation_ResourceAxis_UnitBasis import match_unit_basis_table + + if match_unit_basis_table(node, table, catalog, result): + return + # ⚠ **자원이 열 머리에 오는 표가 따로 있다** (2026-09-08 발견, 39 표). # 「구 분 | 콘크리트공(인) | 보통인부(인)」처럼 **열이 자원**이고 행은 규격 갈래 # (무근·철근·소형구조물)다. 행을 자원으로 읽는 길로 보내면 통째로 못 맞춘다 — diff --git a/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py new file mode 100644 index 00000000..f594434b --- /dev/null +++ b/B09_Estimation/B09_Estimation_ResourceAxis_UnitBasis.py @@ -0,0 +1,151 @@ +"""B09 자원 축 — **한 표에 밑수 단위가 둘인 표** (2026-09-08 발견). + +품셈에는 같은 품을 **㎡당과 ㎥당 두 벌**로 주는 표가 있다. + + 13-2-2 막돌 채집 (단위: ㎥당) + 보통인부 | ㎡당 | 0.17 + | ㎥당 | 0.64 + + 13-2-4 야면석 채집(인력) 뒷길이(㎝) 25 35 45 55 60 + 인부 | ㎡당 0.11 0.17 0.22 0.28 0.36 + | ㎥당 0.60 0.64 0.67 0.70 0.80 + +⚠ **행-자원으로 읽으면 앞줄만 잡고 뒷줄을 버린다.** 실제로 막돌 채집이 **㎡당 0.17 을 +쓰면서 단위는 ㎥** 로 서 있었다(㎥당은 0.64 — **3.8 배**). 야면석은 열이 다섯이라 아예 +안 섰다. 둘 다 조용히 틀린 자리다. + +**그래서 밑수마다 따로 세운다.** 갈래 이름에 밑수를 넣고(`#45㎝·㎥당`), 그 갈래의 +**단위를 그 밑수로** 준다 — 그러면 내역서의 단위 불일치 검사가 **엉뚱한 밑수를 걸러 준다**. + +⚠ **밑수 표기가 하나뿐인 표는 건드리지 않는다.** 그런 표는 여태 하던 길이 맞고, +넓게 잡으면 멀쩡한 표까지 갈래가 둘로 쪼개진다. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_ResourceAxis import ( + AxisResult, + ResourceCatalog, + ResourceRow, + UnmatchedRow, + parse_amount, + split_name_and_spec, +) + +#: 「㎡당」·「㎥당」처럼 **밑수를 말하는 칸**. 앞에 수가 붙으면(「10㎡당」) 여기가 아니라 +#: 밑수(basis_quantity) 자리라 건드리지 않는다. +_RE_BASIS = re.compile(r"^(㎡|㎥|㏊|m|㎝|개|본|주|ton|톤|kg|㎏)\s*당$") + +#: 밑수 표기 → 그 갈래가 갖는 단위. +_BASIS_UNIT = {"㏊": "ha", "톤": "ton", "㎏": "kg"} + + +def _tight(text: str) -> str: + return "".join(str(text or "").split()) + + +def _basis_of(cell: str) -> str | None: + """그 칸이 밑수 표기면 단위, 아니면 `None`.""" + found = _RE_BASIS.match(_tight(cell)) + if not found: + return None + unit = found.group(1) + return _BASIS_UNIT.get(unit, unit) + + +def _column_labels(table: dict[str, Any]) -> list[str]: + """열 머리가 갈래인 표의 갈래 이름들 — 「25 35 45 55 60」. 없으면 빈 목록.""" + headers = [_tight(c) for c in (table.get("condition_note") or [])] + labels = [cell for cell in headers[1:] if cell and parse_amount(cell) is not None] + return labels + + +def match_unit_basis_table( + node: dict[str, Any], + table: dict[str, Any], + catalog: ResourceCatalog, + result: AxisResult, +) -> bool: + """밑수가 둘 이상인 표를 밑수별 갈래로 편다. 그런 표가 아니면 `False`.""" + rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])] + if not rows: + return False + + # 밑수 표기가 **둘 이상** 있어야 이 길이다 — 하나뿐이면 여태 하던 길이 맞다. + seen_basis = {b for row in rows for cell in row if (b := _basis_of(cell))} + if len(seen_basis) < 2: + return False + + work_item_code = str(node.get("work_item_code", "")) + table_id = str(table.get("pum_table_id", "")) + form = str(table.get("pum_form", "")) + labels = _column_labels(table) + + entry = None + made = 0 + for index, cells in enumerate(rows): + basis_at = next((i for i, cell in enumerate(cells) if _basis_of(cell)), None) + if basis_at is None: + continue + if basis_at > 0: + # 이름이 앞에 붙은 줄 — 「인 부 | ㎡당 | 0.11 | …」. 이름을 여기서 갱신한다. + found = _resolve(catalog, cells[0]) + if found is None: + result.unmatched.append( + UnmatchedRow( + work_item_code=work_item_code, + pum_table_id=table_id, + cell=cells[0][:30], + reason="밑수 둘인 표인데 자원 이름을 못 풀었습니다", + ) + ) + return True # 다른 길로 보내지 않는다 — 앞줄만 잡고 뒷줄을 버리게 된다 + entry = found + if entry is None: + continue + + unit = _basis_of(cells[basis_at]) + values = [parse_amount(cell) for cell in cells[basis_at + 1 :]] + values = [v for v in values if v is not None] + if not values: + continue + + for position, amount in enumerate(values): + if labels and position >= len(labels): + break # 비고 칸까지 값으로 읽지 않는다 + label = labels[position] if labels else "" + # ⚠ 갈래 이름에 **밑수를 함께** 적는다 — 안 적으면 ㎡당과 ㎥당이 같은 갈래로 + # 뭉쳐 하나가 덮인다(막돌 채집이 그렇게 ㎡당 값을 ㎥ 단위로 달고 있었다). + variant = f"{label}㎝·{unit}당" if label else f"{unit}당" + result.rows.append( + ResourceRow( + work_item_code=work_item_code, + pum_table_id=table_id, + pum_form=form, + resource_kind=entry.kind, + resource_code=entry.code, + resource_name=entry.name, + resource_spec=entry.spec, + amount=amount, + amount_unit=unit, + raw_row_index=index, + variant=variant, + ) + ) + made += 1 + return made > 0 + + +def _resolve(catalog: ResourceCatalog, name_cell: str): + name, spec = split_name_and_spec(name_cell) + entry = catalog.resolve(name, spec) + if entry is not None: + return entry + if spec: + return None + found = catalog.by_name(name) + return found[0] if len(found) == 1 else None -- 2.54.0 From 81af27f0e822924bdf8e4d3ed3c9f0e458aa0ad5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:48:29 +0900 Subject: [PATCH 430/441] =?UTF-8?q?fix(B08):=20=EB=8F=8C=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=EC=9D=B8=EA=B3=84=EB=A5=BC=20=ED=92=88=EC=85=88=20=EB=B0=91?= =?UTF-8?q?=EC=88=98(=E3=8E=A1)=EB=A1=9C=20=EC=84=B8=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20+=20=EB=B0=91=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EB=8C=80=EC=A1=B0=20=EA=B0=80=EB=93=9C=20=EC=8B=A0?= =?UTF-8?q?=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 실제로 금액이 틀리던 자리 — 돌쌓기(찰)를 `m · 10.0` 으로 보내는데 품셈 13-4-5 밑수는 ㎡ 라, 받는 쪽이 52,938.9원/㎡ 를 그대로 곱해 529,389원이 섰음. 면적으로 세면 26.101㎡ × 52,938.9 = 1,381,753원 — 2.6배. 양쪽 다 오류 없이 조용했음. - `billing_component` 를 매핑에 두고 **전개 성분에서** 내역 수량을 가져옴 (면적 식을 두 벌로 짜지 않음). 돌쌓기·큰돌쌓기 = ㎡ · 비탈면적. - 성분이 안 서면 **연장으로 대신 세지 않음** — 0 + 품셈 밑수 단위로 두고 사유를 냄. - 신설 `B08_Quantity_Engine_BasisUnit.py` — 인계 줄 단위를 **공종 마스터 밑수**와 대조해 `basis_unit_warnings` 로 냄. 표기 차이(㎥/m3·개소/개)만 흡수하고 **환산 안 함**. - 돌 종류를 둘째 갈래 축(`secondary_axes`)으로 실어 보냄 — 품셈 13-4 [주]② 가 「깬돌·깬잡석 전용」이고 돌 종류 표는 13-5 에 따로 있음. 공종 판단은 사용자 확정 대기. - 옛 시험이 「10.0 m」를 계약으로 못 박고 있어 정정(오늘 다섯 번째). 시험 732 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_BasisUnit.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 B08_Quantity/B08_Quantity_Engine_BasisUnit.py diff --git a/B08_Quantity/B08_Quantity_Engine_BasisUnit.py b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py new file mode 100644 index 00000000..677f9fa4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py @@ -0,0 +1,116 @@ +"""품셈 밑수 단위 대조 — **보내는 단위가 그 공종의 밑수와 같은가** (2026-09-08). + +⚠ **왜 있나 — 실제로 금액이 틀렸다.** + 돌쌓기(찰)를 `m · 10.0` 으로 보내는데 품셈 13-4-5 밑수는 **㎡** 였다. 받는 쪽이 그 공종의 + 단가(52,938.9 원/㎡)를 그대로 곱해 **529,389원**이 섰다. 면적으로 세면 26.101㎡ × + 52,938.9 = **1,381,753원** — **2.6배 차이**인데 양쪽 다 오류가 안 났다. + +⚠ **오늘만 다섯 번째 「보내는 쪽과 받는 쪽 사이」 사고다** + 개소가 미터로 나간 것 · 준비공 표가 통째로 안 간 것 · B군 코드가 빈 것 · 성분 줄이 안 + 가는 것 · 이번 단위 불일치. **전부 조용했다.** 한쪽만 보는 시험은 이 자리를 못 잡는다 — + 보내는 값과 **품셈 원문**을 맞대야 잡힌다. 그래서 이 파일은 매핑이 아니라 **마스터**를 본다. + +⚠ **환산하지 않는다.** m 을 ㎡ 로 바꾸는 길은 없다(두께·기울기를 지어내야 한다). + 표기 차이(㎥/m3 · 개소/개)만 같은 것으로 보고, **뜻이 다른 단위는 드러낸다.** +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Iterable + +MASTER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_master" +MASTER_PREFIX = "work_item_master_" + +#: 같은 뜻인데 표기만 다른 단위 — **환산이 아니라 표기 흡수**다. +UNIT_ALIASES = { + "m3": "㎥", + "m³": "㎥", + "m2": "㎡", + "m²": "㎡", + "개": "개소", + "본": "개소", + "kg": "㎏", + "ton": "t", + "톤": "t", +} + + +def normalize_unit(unit: str) -> str: + """표기만 다른 단위를 한 글자로 모은다. **뜻이 다른 단위는 안 건드린다.**""" + text = str(unit or "").strip() + return UNIT_ALIASES.get(text, text) + + +def load_master(path: Path | None = None) -> dict[str, Any]: + """공종 마스터. 파일이 없으면 빈 표 — 대조를 못 할 뿐 값은 그대로 간다.""" + target = path + if target is None: + files = sorted(MASTER_DIR.glob(MASTER_PREFIX + "*.json")) if MASTER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def basis_units(master: dict[str, Any] | None = None) -> dict[str, set[str]]: + """공종코드 → 그 공종 표들이 쓰는 밑수 단위 모음. + + ⚠ 한 공종에 표가 여럿이면 단위도 여럿이다(흄관은 「m」와 「개소」 둘 다 있다). + **하나로 줄이지 않는다** — 줄이면 맞는 단위를 틀렸다고 말하게 된다. + """ + found = master if master is not None else load_master() + out: dict[str, set[str]] = {} + for item in found.get("work_items") or []: + code = str(item.get("work_item_code") or "") + if not code: + continue + units = { + normalize_unit(table.get("basis_unit")) + for table in item.get("tables") or [] + if table.get("basis_unit") + } + if units: + out[code] = units + return out + + +def verify_unit_matches_basis( + rows: Iterable[dict[str, Any]], table: dict[str, set[str]] | None = None +) -> list[str]: + """⚠ 인계 줄의 단위가 품셈 밑수와 다르면 알린다. + + **막지는 않는다** — 여기서 줄을 빼면 「빠진 줄」이 되어 더 안 보인다. 사유를 내고 + 사람이 보게 한다. 코드가 없는 줄·수량이 없는 줄·마스터에 없는 코드는 대상이 아니다. + """ + known = table if table is not None else basis_units() + if not known: + return [] + out: list[str] = [] + for row in rows: + code = str(row.get("work_item_code") or "") + unit = normalize_unit(row.get("unit")) + if not code or not unit or code not in known: + continue + if not row.get("in_bill"): + continue + if unit in known[code]: + continue + expected = " · ".join(sorted(known[code])) + out.append( + f"{row.get('name') or code}({code}) — 보내는 단위 「{unit}」가 품셈 밑수 " + f"「{expected}」와 다릅니다. 그대로 곱하면 금액이 틀립니다" + ) + return out + + +def unit_for_code(code: str, table: dict[str, set[str]] | None = None) -> str: + """그 공종이 **한 가지 밑수 단위만** 쓰면 그 단위, 아니면 빈 문자열. + + ⚠ 물량이 못 서는 줄에도 **단위는 맞는 것**을 실으려고 쓴다 — 「0 m」로 내면 받는 쪽이 + 길이로 읽고, 나중에 값이 채워질 때 축이 어긋난 채로 선다. 여럿이면 고르지 않는다. + """ + known = table if table is not None else basis_units() + units = known.get(str(code or ""), set()) + return next(iter(units)) if len(units) == 1 else "" -- 2.54.0 From 47943f2990e0da9760b3346fe8b9108d00a3ac1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 12:48:34 +0900 Subject: [PATCH 431/441] auto: 2026-09-08 12:48 (EOMSANGDON-HOME) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 72 +++++++++++++++++-- .../work_item_mapping_2026-01-01.json | 10 +++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index f26afd6d..72d65abb 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -45,6 +45,10 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable +from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( + unit_for_code, + verify_unit_matches_basis, +) from B08_Quantity.B08_Quantity_Engine_Preparation import ( STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, ) @@ -515,6 +519,7 @@ def _earthwork_rows( # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": "", # 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다). @@ -570,6 +575,7 @@ def _haul_rows( # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": "", "blocked_kind": None, @@ -617,6 +623,29 @@ def blocked_of( return None, "" +def _component_billing( + structure: dict[str, Any], entry: dict[str, Any] +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. + + ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × + √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. + ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 + 집는다. + """ + name = str(entry.get("billing_component") or "") + if not name: + return None + for component in structure.get("components") or []: + if str(component.get("name") or "") != name: + continue + unit = str(component.get("unit") or "") + amount = float(component.get("amount") or 0.0) + if unit and amount > 0: + return unit, amount + return None + + def _structure_rows( unit_quantity_table: dict[str, Any], mapping: WorkItemMapping ) -> tuple[list[dict[str, Any]], list[str]]: @@ -651,16 +680,31 @@ def _structure_rows( if code and entry.get("class_from") == "back_length": class_key, class_basis = masonry_class(structure.get("options") or {}) + # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 + # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 + # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. + billing = _component_billing(structure, entry) composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None parts: list[dict[str, Any]] | None = None parts_missing: list[dict[str, Any]] = [] if composite: parts, parts_missing = composite_quantities(structure, composite, mapping) - blocked_kind, blocked_reason = blocked_of(structure, class_basis, has_code=bool(code)) + # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — + # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. + counts_by_component = bool(entry.get("billing_component")) + blocked_kind, blocked_reason = blocked_of( + structure, + class_basis, + has_code=bool(code) and (billing is not None or not counts_by_component), + ) # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. variant_axis = str(entry.get("variant_axis") or "") or None variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None + secondary_axes = [ + {"axis": axis, "value": (structure.get("options") or {}).get(axis)} + for axis in entry.get("secondary_axes") or [] + ] if code is None and composite is None: unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") elif entry.get("class_from") in ("back_length", "bond") and class_key is None: @@ -669,12 +713,17 @@ def _structure_rows( # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. - bill_unit = str(structure.get("billing_unit") or "") or "m" - bill_quantity = ( - float(structure.get("billing_quantity") or 0.0) - if structure.get("billing_unit") - else length - ) + if billing is not None: + bill_unit, bill_quantity = billing + elif counts_by_component: + # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 + # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). + bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 + elif structure.get("billing_unit"): + bill_unit = str(structure["billing_unit"]) + bill_quantity = float(structure.get("billing_quantity") or 0.0) + else: + bill_unit, bill_quantity = "m", length rows.append( { "work_item_code": code, @@ -707,6 +756,9 @@ def _structure_rows( # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. "variant_axis": variant_axis, "variant_value": variant_value, + # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. + # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). + "secondary_axes": secondary_axes or None, "spec_class": class_key, "spec_class_basis": class_basis, # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. @@ -788,6 +840,7 @@ def _placing_rows( "blocked_reason": "", "variant_axis": "structure_kind", "variant_value": kind, + "secondary_axes": None, "spec_class": kind, "spec_class_basis": ( "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" @@ -849,6 +902,7 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]] "blocked_reason": "" if ready else str(row.get("reason") or status), "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("reason") or ""), "composite_not_ready": None, @@ -907,6 +961,7 @@ def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. "variant_axis": row.get("variant_axis"), "variant_value": row.get("variant_value"), + "secondary_axes": None, "spec_class": None, "spec_class_basis": str(row.get("blocked_reason") or ""), "composite_not_ready": None, @@ -986,6 +1041,7 @@ def _length_rows( "blocked_reason": reason, "variant_axis": None, "variant_value": None, + "secondary_axes": None, "spec_class": None, "spec_class_basis": note, "composite_not_ready": None, @@ -1107,6 +1163,8 @@ def build_handoff( result["ratio_math_warnings"] = verify_ratio_math(result) result["material_code_warnings"] = verify_no_code_on_materials(result) result["bill_flag_warnings"] = verify_bill_flags(result) + # ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08). + result["basis_unit_warnings"] = verify_unit_matches_basis(work_items) result["placing_notes"] = placing_notes return result diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index c041c79a..f1a2da69 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -116,6 +116,10 @@ "structure": [ { "type_id": "masonry_wet", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", "work_item_code": "FP-13-04-05", "master_name": "돌쌓기 > 찰쌓기(장비)", "note": "인력 시공이면 FP-13-04-04", @@ -124,6 +128,10 @@ }, { "type_id": "masonry_dry", + "secondary_axes": ["stone_kind"], + "secondary_axes_note": "⚠ 돌 종류는 **갈래 축이 하나 더**인 자리다. 품셈 13-4-2·13-4-5 [주]② 가 「본 품은 **깬돌 및 깬 잡석**의 돌쌓기 기준」이라 못 박고, 돌 종류로 갈리는 표는 **13-5 돌붙임**에 따로 있다(뒷길이 7 × 돌종류 6). 어느 쪽 공종으로 볼지는 **사용자 확정 대기** — 여기서 공종을 바꾸지 않고 **저장 원본값만 실어 보낸다**(2026-09-08 계약과 같은 방식).", + "billing_component": "돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다.", "work_item_code": "FP-13-04-02", "master_name": "돌쌓기 > 메쌓기(장비)", "note": "인력 시공이면 FP-13-04-01", @@ -142,6 +150,8 @@ }, { "type_id": "boulder_masonry", + "billing_component": "큰돌쌓기", + "billing_note": "⚠ 품셈 밑수가 「㎡당」이라 **연장(m)으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 m 수량에 곱해 금액이 2.6배로 섰다(2026-09-08 실증: 10m × 52,938.9 = 529,389원 / 26.101㎡ × 52,938.9 = 1,381,753원). 내역 줄 수량은 이 성분(비탈면적)으로 센다. 고임돌·야면석·막자갈은 **자재 축**으로 따로 가므로 여기서 빠지지 않는다. ⚠ 13-6 은 밑수가 **10㎡** 다 — 받는 쪽이 마스터의 `basis_quantity` 를 함께 봐야 한다.", "work_item_code": null, "class_from": "bond", "bond_codes": { -- 2.54.0 From 189108e1f736ef4e8268746cbb615c1154814880 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 13:09:27 +0900 Subject: [PATCH 432/441] =?UTF-8?q?feat(B09):=20=EA=B3=B5=EC=A2=85=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20=EA=B8=B0=EC=A4=80=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EB=A5=BC=20=ED=92=88=EC=85=88=20=EC=9B=90=EB=AC=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B1=84=EC=9B=80=20=E2=80=94=20=EC=B8=B5=EB=94=B0?= =?UTF-8?q?=EA=B8=B0=20=EB=8B=A8=EC=9C=84=20=EB=B6=88=EC=9D=BC=EC=B9=98=20?= =?UTF-8?q?=EB=93=9C=EB=9F=AC=EB=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 211개 중 73개가 단위를 못 달고 있었고, 단위가 없으면 내역서의 단위 불일치 검사가 못 걸림. 층따기가 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었음 (돌쌓기 「m × ㎡당」과 같은 병). - 원문에 적힌 자리 둘에서만 읽음 — 절 머리 아래 「(단위: ㎥당)」 · 공식형 [주]의 「Q= ㎥/시간」. 73개 중 12개를 채웠고 나머지는 미확보로 둠. - ⚠ 「인 당」·「일 당」은 품의 단위이지 공종 단위가 아니라 받지 않음. 틀린 단위로 검사를 통과시키면 없느니만 못함. - 자원 줄이 단위를 주면 그 값이 이김 — 원문 조회는 빈 자리만 채움. - 결과: 층따기 4,102,708원이 「단위가 안 맞습니다」로 바뀜. 본체 합계 7,837,891 → 3,735,183원. ⚠ 줄어든 것이 맞음 — B08 이 ㎥ 로 보내야 풀림. 시험 6건 추가, 249건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_UnitPrice.py | 6 + B09_Estimation/B09_Estimation_WorkItemUnit.py | 139 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 B09_Estimation/B09_Estimation_WorkItemUnit.py diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index 5e05318f..46d2e785 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -55,6 +55,7 @@ from B09_Estimation.B09_Estimation_ResourceAxis import ( load_labor_catalog, load_work_item_master, ) +from B09_Estimation.B09_Estimation_WorkItemUnit import unit_of as work_item_unit from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at _RE_EMPHASIS = re.compile(r"\*\*(.+?)\*\*") @@ -390,6 +391,11 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: break unit = next((r.amount_unit for r in rows if r.amount_unit), "") + if not unit: + # ⚠ 단위가 없으면 **내역서의 단위 불일치 검사가 못 걸린다** — 층따기가 + # 「㎡ 수량 × ㎥당 단가」로 4,102,708원을 내고 있었다. 품셈 원문에 적힌 것만 + # 채우고(「(단위: ㎥당)」·「Q= ㎥/시간」), 없으면 비워 둔다. + unit = work_item_unit(work_item_code) or "" base_name = names.get(work_item_code) or work_item_code build.book.add_title( PriceTitle( diff --git a/B09_Estimation/B09_Estimation_WorkItemUnit.py b/B09_Estimation/B09_Estimation_WorkItemUnit.py new file mode 100644 index 00000000..cdb8ecfb --- /dev/null +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -0,0 +1,139 @@ +"""B09 — **공종 단가의 기준 단위**를 품셈 원문에서 읽는다 (2026-09-08). + +**왜 있는가** — 일위대가 211개 중 **73개가 단위를 못 달고** 있었다. 단위가 없으면 +내역서의 **단위 불일치 검사가 못 걸린다** — 실제로 층따기가 「㎡ 수량 × ㎥당 단가」로 +**4,102,708원**을 내고 있었다(돌쌓기가 「m × ㎡당」이던 것과 같은 병). + +단위가 적힌 자리는 원문에 **둘**이다. + + ① 절 머리 아래 한 줄 — 「### 13-4-5. 찰쌓기(장비)」 다음의 「(단위: ㎡당)」 + ② 공식형 표의 [주] — 「Q1=3600×q×K×f×E/㎝= **㎥/시간**」 (층따기 9-18) + +⚠ **「인 당」·「일 당」은 공종 단위가 아니다.** 그것은 **품의 단위**(사람·날)라, 공종 +단위로 쓰면 「몇 인짜리 공종」이라는 뜻이 된다. 그래서 **물리 수량 단위만** 받는다. + +⚠ **없으면 비워 둔다.** 마스터 `basis_unit` 도 대개 비어 있고(73개 중 대부분 `None`), +못 찾은 것을 짐작으로 채우면 **틀린 단위로 검사를 통과**시켜 오히려 더 나쁘다. +「미확보」로 두면 화면이 「기준 단위가 표에 없습니다」로 드러낸다. +""" + +from __future__ import annotations + +import os +import re +from functools import lru_cache + +#: 산림사업 표준품셈 원문 (FP-* 공종의 출처). +_FOREST_SPEC = ( + "resources", + "knowledge", + "original", + "행정규칙", + "임도 품셈 적용기준 (현 산림사업 표준품셈)", + "첨부", + "(산림청고시 제2025-82호) 산림사업 표준품셈.md", +) + +#: **공종 수량 단위로 인정하는 것.** 여기 없는 표기(인·일·회·시간)는 품의 단위이지 +#: 공종의 단위가 아니다 — 받으면 「몇 인짜리 공종」이 된다. +_QUANTITY_UNITS = frozenset( + { + "㎡", + "㎥", + "m", + "㎞", + "개", + "개소", + "본", + "주", + "ton", + "톤", + "㏊", + "ha", + "kg", + "㎏", + "매", + "장", + "식", + } +) + +_RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$") +#: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다. +_RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[::]\s*(.+?)\s*\)\s*$") +#: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. +_RE_FORMULA_UNIT = re.compile(r"=\s*([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b") + + +def _project_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _clean_unit(text: str) -> str | None: + """「100㎡당」 → 「㎡」. 공종 단위가 아니면 `None`.""" + tight = "".join(str(text).split()) + tight = re.sub(r"^\d+(?:,\d{3})*(?:\.\d+)?", "", tight) # 밑수 수량은 뗀다 + tight = tight[:-1] if tight.endswith("당") else tight + return tight if tight in _QUANTITY_UNITS else None + + +@lru_cache(maxsize=1) +def section_units() -> dict[str, str]: + """절 번호 → 공종 단위. **원문에 적힌 것만** 담는다.""" + path = os.path.join(_project_root(), *_FOREST_SPEC) + try: + lines = open(path, encoding="utf-8").read().splitlines() + except OSError: + return {} + + units: dict[str, str] = {} + section: str | None = None + for raw in lines: + line = raw.strip() + heading = _RE_HEADING.match(line) + if heading: + section = heading.group(1) + # ⚠ 절 제목 자체가 식을 품는 경우가 있다 — 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」. + found = _RE_FORMULA_UNIT.search(heading.group(2)) + if found and section not in units: + units[section] = found.group(1) + continue + if section is None or section in units: + continue + unit_line = _RE_UNIT_LINE.match(line) + if unit_line: + cleaned = _clean_unit(unit_line.group(1)) + if cleaned: + units[section] = cleaned + continue + found = _RE_FORMULA_UNIT.search(line) + if found: + units[section] = found.group(1) + return units + + +def section_of(work_item_code: str) -> str: + """공종 코드 → 절 번호. `FP-13-04-05` → `13-4-5`.""" + parts = str(work_item_code or "").split("-") + if not parts or parts[0] != "FP": + return "" + numbers = [] + for part in parts[1:]: + if not part.isdigit(): + return "" + numbers.append(str(int(part))) + return "-".join(numbers) + + +def unit_of(work_item_code: str) -> str | None: + """그 공종 단가의 기준 단위. **원문에 없으면 `None`** — 짐작으로 채우지 않는다.""" + section = section_of(work_item_code) + if not section: + return None + units = section_units() + # 하위 절에 안 적혀 있으면 **한 단계 위 절**을 본다 — 「13-4-5」가 없으면 「13-4」. + # ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다. + if section in units: + return units[section] + parent = section.rsplit("-", 1)[0] + return units.get(parent) if parent != section and "-" in section else None -- 2.54.0 From 027e059ab8bfaff7b8a36e30e239f5a336ae467c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 13:16:30 +0900 Subject: [PATCH 433/441] =?UTF-8?q?fix(B08):=20=EC=B8=B5=EB=94=B0=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=A9=B4=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=95=88=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EB=8F=84=EB=A1=9D=20=EB=A7=89?= =?UTF-8?q?=EA=B3=A0,=20=EB=A7=A4=ED=95=91=EC=9D=B4=20=EC=A0=81=EC=9D=80?= =?UTF-8?q?=20=EB=B0=91=EC=88=98=EA=B9=8C=EC=A7=80=20=EB=8C=80=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 또 하나의 단위 불일치 — 층따기 9-18 은 [주] 공식 `Q1 = 3600×q×K×f×E/㎝ = ㎥/시간` 이라 단가가 ㎥당인데 우리는 성토 비탈면적 ㎡ 2,645.21 을 보내고 있었음. 그대로 곱혀 4,102,708원이 서 있었음(B09 실측). - 매핑에 `basis_unit`·`basis_source`·`mismatch_reason` 을 두고, 우리 단위와 뜻이 다르면 **환산하지 않고** 막음(`input_missing` — 층따기 단 높이·폭은 설계 입력). 막힌 줄에도 집계값을 `spec_detail` 에 남겨 되짚게 함. - ⚠ 절 머리에 「(단위: …)」가 없는 공종은 마스터 `basis_unit` 이 빔(477 중 167만 참). 그래서 밑수 대조가 조용히 통과하고 있었음 — 매핑이 원문에서 읽어 적은 밑수를 `declared_units()` 로 함께 넘겨 대조가 서게 함. - 성토면다짐(9-17-1)은 시공량이 ㎡/시간이라 면적이 맞음 — 좁게 막아 형제 공종은 그대로. 시험 736 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_BasisUnit.py | 23 ++++++- B08_Quantity/B08_Quantity_Engine_Handoff.py | 63 ++++++++++++++++--- .../work_item_mapping_2026-01-01.json | 6 +- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_BasisUnit.py b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py index 677f9fa4..c56424cb 100644 --- a/B08_Quantity/B08_Quantity_Engine_BasisUnit.py +++ b/B08_Quantity/B08_Quantity_Engine_BasisUnit.py @@ -76,15 +76,34 @@ def basis_units(master: dict[str, Any] | None = None) -> dict[str, set[str]]: return out +def merge_units(table: dict[str, set[str]], extra: dict[str, str] | None) -> dict[str, set[str]]: + """마스터 밑수에 **매핑이 원문에서 읽어 적은 밑수**를 얹는다. + + ⚠ **왜 얹나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서만 온다. 층따기 9-18 + 처럼 **공식 [주]로만 단위가 밝혀지는 공종**은 그 자리가 비어(477 중 밑수가 선 것은 167) + 대조가 조용히 통과한다. 매핑이 적은 값은 **마스터가 빈 자리를 채우고, 있으면 함께 둔다** + (하나로 줄이면 맞는 단위를 틀렸다고 말하게 된다). + """ + if not extra: + return table + merged = {code: set(units) for code, units in table.items()} + for code, unit in extra.items(): + if unit: + merged.setdefault(code, set()).add(normalize_unit(unit)) + return merged + + def verify_unit_matches_basis( - rows: Iterable[dict[str, Any]], table: dict[str, set[str]] | None = None + rows: Iterable[dict[str, Any]], + table: dict[str, set[str]] | None = None, + extra: dict[str, str] | None = None, ) -> list[str]: """⚠ 인계 줄의 단위가 품셈 밑수와 다르면 알린다. **막지는 않는다** — 여기서 줄을 빼면 「빠진 줄」이 되어 더 안 보인다. 사유를 내고 사람이 보게 한다. 코드가 없는 줄·수량이 없는 줄·마스터에 없는 코드는 대상이 아니다. """ - known = table if table is not None else basis_units() + known = merge_units(table if table is not None else basis_units(), extra) if not known: return [] out: list[str] = [] diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 72d65abb..a1851495 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -46,6 +46,7 @@ from pathlib import Path from typing import Any, Iterable from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( + normalize_unit, unit_for_code, verify_unit_matches_basis, ) @@ -210,6 +211,20 @@ class WorkItemMapping: #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. pipe: dict[str, Any] = field(default_factory=dict) + def declared_units(self) -> dict[str, str]: + """공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다. + + ⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가 + 밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를 + 함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다. + """ + found: dict[str, str] = {} + for row in (*self.earthwork, *self.haul, *self.structure): + code, unit = row.get("work_item_code"), row.get("basis_unit") + if code and unit: + found[str(code)] = str(unit) + return found + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" exact = [ @@ -470,6 +485,27 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple return None, NOTE_METHOD_MISSING +def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: + """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. + + ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, + **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 + `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — + 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. + ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. + """ + declared = str((entry or {}).get("basis_unit") or "") + if not declared or not unit: + return None + if normalize_unit(unit) == normalize_unit(declared): + return None + return ( + declared, + str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), + str((entry or {}).get("mismatch_reason") or ""), + ) + + def _earthwork_rows( summary_table: dict[str, Any], mapping: WorkItemMapping, @@ -492,6 +528,15 @@ def _earthwork_rows( lookup_ground, method_note = _mapping_ground(ground, methods) entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None code = (entry or {}).get("work_item_code") + # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). + # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. + unit = str(row.get("unit") or "㎥") + amount = float(row.get("amount") or 0.0) + mismatch = _basis_mismatch(entry, unit) if code else None + spec_detail = "" + if mismatch is not None: + spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" + unit, amount = mismatch[0], 0.0 if code is None and not is_subtotal: label = f"{group}({ground})" if ground else group unmatched.append(f"{label} — {method_note}" if method_note else label) @@ -500,8 +545,8 @@ def _earthwork_rows( "work_item_code": code, "name": group, "spec": str(row.get("spec") or ""), - "unit": str(row.get("unit") or "㎥"), - "quantity": float(row.get("amount") or 0.0), + "unit": unit, + "quantity": amount, # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. "quantity_gross": row.get("amount_gross"), "application_ratio_pct": row.get("application_ratio_pct"), @@ -513,7 +558,7 @@ def _earthwork_rows( "haul_equipment": None, "station_from": None, "station_to": None, - "spec_detail": "", + "spec_detail": spec_detail, "composite_parts": None, "structure_kind": None, # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). @@ -522,12 +567,12 @@ def _earthwork_rows( "secondary_axes": None, "spec_class": None, "spec_class_basis": "", - # 토공 줄은 막힐 자리가 없다 — 그래도 **칸은 둔다**(계약이 한 모양이어야 한다). - "blocked_kind": None, - "blocked_reason": "", + # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. + "blocked_kind": mismatch[1] if mismatch else None, + "blocked_reason": mismatch[2] if mismatch else "", "composite_not_ready": None, # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. - "in_bill": bool(row.get("in_bill", True)) and not is_subtotal, + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, "excavation_method": methods.get(ground) if ground else None, "in_bill_reason": "집계 합계 줄 — 검산용" if is_subtotal @@ -1164,7 +1209,9 @@ def build_handoff( result["material_code_warnings"] = verify_no_code_on_materials(result) result["bill_flag_warnings"] = verify_bill_flags(result) # ⚠ 보내는 단위가 **품셈 밑수**와 같은가 — 받는 쪽이 그대로 곱하는 자리다(2026-09-08). - result["basis_unit_warnings"] = verify_unit_matches_basis(work_items) + result["basis_unit_warnings"] = verify_unit_matches_basis( + work_items, extra=table.declared_units() + ) result["placing_notes"] = placing_notes return result diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index f1a2da69..49a596c8 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -64,7 +64,11 @@ { "group": "층따기", "work_item_code": "FP-09-18", - "master_name": "층따기" + "master_name": "층따기", + "basis_unit": "㎥", + "basis_source": "품셈 9-18 [주] 「Q1 = 3600×q×K×f×E/㎝ = ㎥/시간」 — 절 머리에 「(단위: …)」가 없고 **공식으로만** 단위가 밝혀지는 자리라 마스터 `basis_unit` 이 비어 있다(2026-09-08 B09 확인).", + "mismatch_reason": "층따기는 품셈이 **체적(㎥)**으로 세는데 우리 집계는 **성토 비탈면적(㎡)** 입니다 — 층따기 단의 높이·폭이 있어야 체적이 나옵니다(교본: 「층따기 높이·폭은 설계도서에 명시」). 그 값이 정해지면 물량이 섭니다.", + "mismatch_kind": "input_missing" }, { "group": "면고르기", -- 2.54.0 From 843b62cca9b03bfb4f0844e2ee40c7c2a12258d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 13:22:27 +0900 Subject: [PATCH 434/441] =?UTF-8?q?feat(B09):=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=9D=BD=EA=B8=B0=20=EB=84=93=ED=9E=98=20?= =?UTF-8?q?=E2=80=94=20=EC=83=81=EC=9C=84=20=EC=A0=88=C2=B7=EC=88=98=20?= =?UTF-8?q?=EB=82=80=20=ED=91=9C=EA=B8=B0=EA=B9=8C=EC=A7=80,=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=20=EC=9E=A5=20=EB=8B=A8=EC=9C=84=EB=8A=94=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「A = 77.7 ㎡/시간」처럼 등호와 단위 사이에 수가 끼는 표기를 읽음 (9-17-1 비탈면 다짐이 그래서 통째로 미확보였음). - 상위 절이 스스로 안 적고 하위만 적는 경우를 채움 — 암절취 9-4 · 발파암 9-5 · 노체 9-16 · 덤프운반 10-12 가 전부 ㎥. - ⚠ 좁힘: 말한 하위가 둘 이상이고 값이 하나로 모일 때만. 하나만 말한 것을 상위에 주면 장 전체가 그 단위가 됨 — 「13. 돌공사」가 실제로 ton 으로 나왔음. - 단위 빈 일위대가 73 → 61. 시험 3건 추가, 252건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_WorkItemUnit.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_WorkItemUnit.py b/B09_Estimation/B09_Estimation_WorkItemUnit.py index cdb8ecfb..d3f3bdf5 100644 --- a/B09_Estimation/B09_Estimation_WorkItemUnit.py +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -62,7 +62,9 @@ _RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$") #: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다. _RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[::]\s*(.+?)\s*\)\s*$") #: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. -_RE_FORMULA_UNIT = re.compile(r"=\s*([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b") +#: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐). +#: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다. +_RE_FORMULA_UNIT = re.compile(r"=\s*(?:[\d.,]+\s*)?([㎡㎥m]+)\s*/\s*(?:시간|hr|h)\b") def _project_root() -> str: @@ -135,5 +137,23 @@ def unit_of(work_item_code: str) -> str | None: # ⚠ 두 단계 위까지는 안 올라간다. 장(13) 전체는 공종이 섞여 단위가 하나가 아니다. if section in units: return units[section] + + # ⚠ **상위 절은 스스로 단위를 안 적고 하위 절만 적는 경우가 많다** — 「9-5. 발파암」은 + # 빈 제목이고 9-5-1·9-5-2·9-5-3 이 각각 ㎥ 를 밝힌다(암절취 9-4·노체 9-16·덤프운반 + # 10-12 도 같다). **하위가 모두 같은 단위일 때만** 그 단위를 상위에 준다 — + # 갈리면 안 준다(한 단위로 뭉뚱그리면 어느 하위가 틀렸는지 못 가린다). + # + # ⚠ **둘 이상이 말할 때만 듣는다.** 하나만 말한 것을 상위에 주면 장(章) 전체가 + # 그 하나의 단위가 된다 — 실제로 「13. 돌공사」가 하위 한 절 때문에 **ton** 으로 + # 나왔다(2026-09-08). 그래서 ㉠ 절 번호에 하이픈이 있고(장 자체가 아니고) + # ㉡ **말한 하위가 둘 이상**이며 ㉢ 그 값이 하나로 모일 때만 준다. + declared = [ + value + for key, value in units.items() + if key.startswith(f"{section}-") and key.count("-") == section.count("-") + 1 + ] + if "-" in section and len(declared) >= 2 and len(set(declared)) == 1: + return declared[0] + parent = section.rsplit("-", 1)[0] return units.get(parent) if parent != section and "-" in section else None -- 2.54.0 From 66099e79a0283bf71459204914440847bc22b401 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 13:25:58 +0900 Subject: [PATCH 435/441] =?UTF-8?q?data(B08):=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0=EB=A1=9C=20=EB=B0=91=EC=88=98=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=9D=BC=EA=B3=B1=EC=9D=84=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=EC=97=90=20=EC=B1=84=EC=9A=B0=EA=B3=A0,=20=EB=8D=A4=ED=94=84?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=EC=A4=91=EB=9F=89=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 마스터가 못 채운 자리(절 머리에 「(단위: …)」가 없는 공종)를 원문에서 읽어 적음. 줄 번호까지 열어 확인했고, 시험이 그 줄에 그 글자가 있는지 매번 대조함. FP-09-03-02 기계(흙깎기) ㎥ L4704 · FP-09-04 암절취 ㎥ L4725·L4738 FP-09-05 발파암 ㎥ L4744·L4777 · FP-09-16 노체 ㎥ L5322·L5336·L5363 FP-09-17-01 비탈면 다짐 ㎡ L5368 · FP-10-11 불도저 운반 ㎥ L5919 FP-10-12 덤프 운반 ㎥ L5928·L5971·L5985 ⚠ FP-12-06 은 「없음」이 아니라 **거절** — L6229 「(단위: 일당)」은 품의 단위이지 공종 수량 단위가 아님. 받으면 틀린 단위로 검사를 통과시킴. 매핑에 사유를 적음. ⚠ 덤프 단위중량 γt 가 원문에 있었음(「현장값이라 미확보」 정정) — L5930 [주]② 「토사 1.9ton/㎥, 암절취 및 발파암은 2.4ton/㎥」. 갈래별로 값이 다름. 시험 739 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- .../work_item_mapping_2026-01-01.json | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index 49a596c8..bba043fc 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -17,6 +17,8 @@ "group": "흙깎기", "ground": "토사", "work_item_code": "FP-09-03-02", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4704 「Q=3600×q×k×f×E/㎝= ㎥/hr」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "토사깍기 > 기계", "note": "인력 시공이면 FP-09-03-01" }, @@ -24,12 +26,16 @@ "group": "흙깎기", "ground": "리핑암", "work_item_code": "FP-09-04", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4725·L4738 「Q(시간당 작업량 ㎥/hr)」·「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "암절취" }, { "group": "흙깎기", "ground": "발파암", "work_item_code": "FP-09-05", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L4744 「(단위: ㎥당)」 · L4777 「Q=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "발파암" }, { @@ -53,12 +59,16 @@ { "group": "성토", "work_item_code": "FP-09-16", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5322·L5336·L5363 세 하위 모두 「= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "노체", "note": "포설(FP-09-16-01)·다짐(FP-09-16-02)로 갈리는 자리 — 내역 양식이 정해지면 내린다" }, { "group": "성토면다짐", "work_item_code": "FP-09-17-01", + "basis_unit": "㎡", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5368 「9-17-1. 비탈면 다짐 : A = 77.7 ㎡/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "다짐 > 비탈면 다짐" }, { @@ -103,11 +113,16 @@ { "equipment": "dozer", "work_item_code": "FP-10-11", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5919 「Q=60×q×f×E/㎝=㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "불도저 운반" }, { "equipment": "dump_truck", + "payload_density_note": "⚠ 적재 재료의 **단위중량 γt 가 원문에 있다** — L5930 [주]② 「적재 재료의 단위중량은 **토사 1.9ton/㎥, 암절취 및 발파암은 2.4ton/㎥** 적용한다」. 앞서 「현장값이라 미확보」로 두었던 자리가 원문으로 풀렸다(2026-09-08). ⚠ **갈래별로 값이 다르므로** `ground_class` 를 함께 봐야 한다. 남은 것은 운반거리 L(설계에서 옴)과 덤프 적재용량 T 둘뿐이다.", "work_item_code": "FP-10-12", + "basis_unit": "㎥", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L5928·L5971·L5985 세 하위 모두 「Q1=…= ㎥/시간」 — 절 머리에 「(단위: …)」가 없어 마스터가 못 채운 자리(2026-09-08 원문 대조).", "master_name": "덤프 운반" }, { @@ -150,7 +165,8 @@ { "type_id": "ford_pavement", "work_item_code": "FP-12-06", - "master_name": "콘크리트 포장(인력시공)" + "master_name": "콘크리트 포장(인력시공)", + "basis_unit_note": "⚠ 원문 L6229 는 「(단위: **일당**)」인데 그것은 **품의 단위**(하루에 얼마)이지 공종 수량 단위가 아니다 — 밑수로 받지 않는다. 작업조형 표라 시공량으로 나눠야 수량 단위가 나온다(2026-09-08 B09 확인). 밑수가 설 때까지 대조 대상이 아니다." }, { "type_id": "boulder_masonry", -- 2.54.0 From 510337557b4aa2e9addbcf6c6e6e997da45da7a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 14:13:42 +0900 Subject: [PATCH 436/441] =?UTF-8?q?fix(B08):=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=E3=80=8C=EB=AC=BC=EA=B5=AC=EB=A9=8D?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=E3=80=8C=EB=AC=BC=EA=B5=AC=EB=A9=8D?= =?UTF-8?q?=EA=B4=80=E3=80=8D=20(=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EA=B7=9C=EC=95=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자재 카탈로그가 이름+규격 두 문자열로 줄을 찾고, 그 규약이 「공백 없는 한 낱말」임 (2026-09-08 B09 실측 — 키는 나라장터 물품식별번호라 우리가 맞출 것이 아님). 전개·관측 원단위표·할증표 세 곳을 함께 고침 — 한쪽만 고치면 이름이 갈림. ⚠ 고임돌·야면석 규격에 뒷길이를 넣지 않음 — 뒷길이는 **수량이 갈리는 축**이지 단가가 갈리는 축이 아님(야면석 톤당 단가는 뒷길이와 무관). B09 가 앞 제안을 물림. 곁들여 — 물구멍관 길이 0.5 가 구조물마다 다른 값임을 주석에 적음 (옹벽 0.32 m/m · 돌기슭막이 0.39 m · 돌골막이 0.5 m/개소, 실무 관측). 시험 739 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_UnitQuantity.py | 11 +++++++---- .../material_surcharge_2026-01-01.json | 2 +- .../structure_unit_observed_2026-01-01.json | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 2ba08c1a..5b9e2317 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -144,7 +144,10 @@ STONE_MASONRY = { # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 - "weep_hole_length_m": 0.5, # 물구멍 1개소당 관 길이 + # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · + # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 + # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. + "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) "excavation_extra_m": 0.2, # 터파기 폭 여유 "backfill_thickness_m": 0.2, # 되메우기 두께 @@ -168,7 +171,7 @@ DESTINATION = { "채움콘크리트": "unit_price", "모르터": "unit_price", "거푸집": "unit_price", - "물구멍": "material", + "물구멍관": "material", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) } # ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. @@ -513,10 +516,10 @@ def stone_masonry( # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. components.append( Component( - "물구멍", + "물구멍관", "m", masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], - DESTINATION["물구멍"], + DESTINATION["물구멍관"], # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). "돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·" diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index a7eb5b0d..3451db13 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -158,7 +158,7 @@ }, "not_found": { "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", - "materials": ["야면석", "고임돌", "물구멍"], + "materials": ["야면석", "고임돌", "물구멍관"], "checked": [ "산림품셈 1-3-1 전 19종", "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", diff --git a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json index 05b9fb3b..4c48dd52 100644 --- a/resources/data_structure_unit/structure_unit_observed_2026-01-01.json +++ b/resources/data_structure_unit/structure_unit_observed_2026-01-01.json @@ -62,7 +62,7 @@ "basis_note": "기초" }, { - "name": "물구멍", + "name": "물구멍관", "unit": "m", "amount": 0.32, "destination": "material", -- 2.54.0 From 5063bd376b048a6a33beac0e6b5b5c1e021f8f5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 14:15:33 +0900 Subject: [PATCH 437/441] =?UTF-8?q?feat(B09):=20=E3=80=8C=EB=8B=A8?= =?UTF-8?q?=EC=9C=84:=E3=80=8D=20=EC=97=86=EC=9D=B4=20=EA=B4=84=ED=98=B8?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=81=EB=8A=94=20=EC=A0=88=EB=8F=84=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20=E2=80=94=20=EB=8B=A8=EC=9C=84=20=EB=B9=88=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=2061=20=E2=86=92=2027?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」처럼 「단위:」를 뺀 절이 있음. 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 미확보였음(메인이 물은 아홉 중 하나). - 「인/」 앞머리는 품의 단위라 건너뜀 — 「인/㎡당」은 「㎡당 몇 인」이라 공종 단위가 ㎡. - ⚠ 「㎥/1대, 1일」·「대/ton」은 안 받음 — 그건 시공량이지 소요량이 아니라 공종 단위로 읽으면 뜻이 뒤집힘(역수). 밑수가 둘인 「1ha당, 100본당」도 안 받음. - 절 단위 128 → 185, 단위 빈 일위대가 61 → 27. 내역 금액 변동 없음. 시험 2건 추가(받을 것·막을 것 짝), 254건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B09_Estimation/B09_Estimation_WorkItemUnit.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_WorkItemUnit.py b/B09_Estimation/B09_Estimation_WorkItemUnit.py index d3f3bdf5..ec05eb27 100644 --- a/B09_Estimation/B09_Estimation_WorkItemUnit.py +++ b/B09_Estimation/B09_Estimation_WorkItemUnit.py @@ -61,6 +61,15 @@ _QUANTITY_UNITS = frozenset( _RE_HEADING = re.compile(r"^#{2,4}\s*(\d+(?:-\d+)*)\s*\.\s*(.*)$") #: 「(단위: ㎡당)」·「(단위 : 100㎡당)」. 앞에 붙은 수는 밑수라 여기서는 버린다. _RE_UNIT_LINE = re.compile(r"^\(\s*단위\s*[::]\s*(.+?)\s*\)\s*$") +#: 「단위:」를 빼고 **괄호만** 적는 절이 있다 — 「(㎡당)」·「(인/100㎡당)」·「(1,000본당)」. +#: 씨앗뿜어붙이기 5-24 가 그 모양이라 통째로 안 읽히고 있었다(2026-09-08). +#: +#: ⚠ **앞의 「인/」은 품의 단위이고 뒤가 공종 단위다** — 「인/㎡당」은 「㎡당 몇 인」이라 +#: 공종 단위가 ㎡ 다. 그래서 그것만 건너뛴다. +#: ⚠ **「㎥/1대, 1일」·「대/ton」 같은 것은 안 받는다** — 그건 소요량이 아니라 +#: **시공량**(1대가 하루에 몇 ㎥)이라, 공종 단위로 읽으면 뜻이 뒤집힌다. +#: 쉼표가 들어간 것(「1ha당, 100본당」)도 밑수가 둘이라 안 받는다. +_RE_BARE_UNIT_LINE = re.compile(r"^\(\s*(?:인\s*/\s*)?(?:[\d.,]+\s*)?([^\s/,()]+?)\s*당?\s*\)\s*$") #: 공식이 스스로 밝히는 결과 단위 — 「= ㎥/시간」·「㎥/hr」. #: ⚠ 등호와 단위 사이에 **수가 끼는 표기**가 있다 — 「A = 77.7 ㎡/시간」(9-17-1 비탈면 다짐). #: 그 수를 건너뛰지 않으면 그 절이 통째로 안 읽힌다. @@ -102,7 +111,7 @@ def section_units() -> dict[str, str]: continue if section is None or section in units: continue - unit_line = _RE_UNIT_LINE.match(line) + unit_line = _RE_UNIT_LINE.match(line) or _RE_BARE_UNIT_LINE.match(line) if unit_line: cleaned = _clean_unit(unit_line.group(1)) if cleaned: -- 2.54.0 From c81c11e330d77c1dc15f06e9e13d5bb3b25f1125 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 14:16:17 +0900 Subject: [PATCH 438/441] =?UTF-8?q?refactor(B08):=20Engine=5FHandoff=201,2?= =?UTF-8?q?79=EC=A4=84=EC=9D=84=20=EC=85=8B=EC=9C=BC=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EB=A6=84=20(700=EC=A4=84=20=EC=A0=9C=ED=95=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ **계약은 하나도 안 바뀜** — 줄의 모양·칸 이름 그대로이고 파일만 가름. 종전 이름으로 부르던 곳이 그대로 돌도록 `Engine_Handoff` 가 다시 내보냄. Engine_Handoff.py 249줄 build_handoff·검사·다시 내보내기 Engine_Handoff_Mapping.py 413줄 매핑표·WorkItemMapping·갈래표·묶음 전개 Engine_Handoff_Rows.py 700줄 줄 빌더 여덟(토공·운반·구조물·준비공·배수관·연장·타설) 확인 — 시험 739 통과로 가르기 전과 같음(B05 코리도 1건 기존 깨짐, 무관). 다시 내보내는 이름 22개를 실제로 불러 봄. Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Engine_Handoff.py | 1115 +---------------- .../B08_Quantity_Engine_Handoff_Mapping.py | 410 ++++++ .../B08_Quantity_Engine_Handoff_Rows.py | 702 +++++++++++ 3 files changed, 1155 insertions(+), 1072 deletions(-) create mode 100644 B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py create mode 100644 B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index a1851495..b24a0e0d 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -40,1084 +40,55 @@ from __future__ import annotations -import json -from dataclasses import dataclass, field -from pathlib import Path from typing import Any, Iterable -from B08_Quantity.B08_Quantity_Engine_BasisUnit import ( - normalize_unit, - unit_for_code, - verify_unit_matches_basis, +from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis + +# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게 +# 여기서 다시 내보낸다(2026-09-08 분리). +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401 + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + REBAR_PREFIXES, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + euroform_type, + load_mapping, + load_masonry_table, + load_rebar_table, + load_timber_table, + masonry_class, + normalize_kind_key, + placing_code, + rebar_complexity, + structure_kind, + timber_class, ) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import ( # noqa: F401 + PLACING_TARGET_NAMES, + _earthwork_rows, + _haul_rows, + _length_rows, + _material_rows, + _pipe_rows, + _placing_rows, + _preparation_rows, + _structure_rows, + blocked_of, ) -from B08_Quantity.B08_Quantity_Engine_Preparation import ( - STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, -) -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING -from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY from common_util.common_util_quantity_spread import spread_by_unit -DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" -DATASET_PREFIX = "work_item_mapping_" - -#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이 -#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다. -REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" -REBAR_PREFIX = "rebar_complexity_" - -#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다). -MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" -MASONRY_PREFIX = "masonry_class_" - - -#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다. -TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber" -TIMBER_PREFIX = "timber_structure_class_" - - -def load_timber_table(path: Path | None = None) -> dict[str, Any]: - """목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" - target = path - if target is None: - files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else [] - target = files[-1] if files else None - if target is None or not target.is_file(): - return {} - return json.loads(target.read_text(encoding="utf-8")) - - -def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]: - """(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.** - - ⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다. - 밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③). - """ - found = table if table is not None else load_timber_table() - for row in (found or {}).get("type_map") or []: - if row.get("type_id") == type_id and row.get("class"): - basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」" - if row.get("provisional"): - basis += f" · ⚠ 잠정 — {row.get('compare', '')}" - return str(row["class"]), basis, bool(row.get("provisional")) - return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False - - -def load_masonry_table(path: Path | None = None) -> dict[str, Any]: - """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" - target = path - if target is None: - files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else [] - target = files[-1] if files else None - if target is None or not target.is_file(): - return {} - return json.loads(target.read_text(encoding="utf-8")) - - -def masonry_class( - options: dict[str, Any], table: dict[str, Any] | None = None -) -> tuple[str | None, str]: - """돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다. - - ⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다. - **저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다. - """ - found = table if table is not None else load_masonry_table() - spec = (found or {}).get("back_length") or {} - from B08_Quantity.B08_Quantity_Wording import option_missing - - option_key = str(spec.get("option_key") or "back_len_cm") - raw = options.get(option_key) - if raw is None: - # ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지. - # 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다. - return None, option_missing(option_key) + " (단가 갈래를 못 고름)" - try: - value = float(raw) - except (TypeError, ValueError): - return None, f"뒷길이 값을 못 읽음({raw!r})" - for row in spec.get("classes") or []: - if value <= float(row["max_cm"]): - return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간" - return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" - - -from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label - - -def normalize_kind_key(label: str) -> str: - """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). - - 원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다. - ⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을 - 여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다. - """ - return "".join(str(label).split()) - - -#: 줄이 어디서 왔나 — 되짚을 때 쓴다. -ORIGIN_EARTHWORK = "earthwork" -ORIGIN_STRUCTURE = "structure" -ORIGIN_SLOPE = "slope" -ORIGIN_HAUL = "haul" -ORIGIN_PREPARATION = "preparation" -ORIGIN_PIPE = "pipe" - -#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 -#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). -METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} -NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" - -#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 — -#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이 -#: 아니라 공종 이름이라 성분 목록에는 안 온다. -REBAR_PREFIXES = ("이형철근", "원형철근", "철근") - -#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 -#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다. -BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 -BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 -BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 - -#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. -SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) - -#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. -SUBTOTAL_GROUPS = frozenset({"보정량계"}) - - -def _latest_dataset_path(directory: Path | None = None) -> Path | None: - folder = directory or DATASET_DIR - if not folder.is_dir(): - return None - files = sorted(folder.glob(DATASET_PREFIX + "*.json")) - return files[-1] if files else None - - -@dataclass -class WorkItemMapping: - """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" - - effective_date: str = "" - earthwork: list[dict[str, Any]] = field(default_factory=list) - haul: list[dict[str, Any]] = field(default_factory=list) - structure: list[dict[str, Any]] = field(default_factory=list) - pending_user: dict[str, Any] = field(default_factory=dict) - composite: dict[str, Any] = field(default_factory=dict) - concrete_placing: dict[str, Any] = field(default_factory=dict) - unit_conversion: dict[str, Any] = field(default_factory=dict) - #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. - pipe: dict[str, Any] = field(default_factory=dict) - - def declared_units(self) -> dict[str, str]: - """공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다. - - ⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가 - 밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를 - 함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다. - """ - found: dict[str, str] = {} - for row in (*self.earthwork, *self.haul, *self.structure): - code, unit = row.get("work_item_code"), row.get("basis_unit") - if code and unit: - found[str(code)] = str(unit) - return found - - def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 - """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" - exact = [ - row - for row in self.earthwork - if row.get("group") == group and row.get("ground") == ground - ] - if exact: - return exact[0] - # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. - loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] - return loose[0] if loose else None - - def for_haul(self, equipment: str) -> dict[str, Any] | None: - for row in self.haul: - if row.get("equipment") == equipment: - return row - return None - - def for_structure(self, type_id: str) -> dict[str, Any] | None: - for row in self.structure: - if row.get("type_id") == type_id: - return row - return None - - def composite_for(self, type_id: str) -> dict[str, Any] | None: - """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. - - 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. - """ - for row in self.composite.get("items") or []: - if row.get("type_id") == type_id: - return row - return None - - -def load_mapping(path: Path | None = None) -> WorkItemMapping: - """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" - target = path or _latest_dataset_path() - if target is None or not target.is_file(): - return WorkItemMapping() - payload = json.loads(target.read_text(encoding="utf-8")) - return WorkItemMapping( - effective_date=str(payload.get("effective_date") or ""), - earthwork=list(payload.get("earthwork") or []), - haul=list(payload.get("haul") or []), - structure=list(payload.get("structure") or []), - pending_user=payload.get("pending_user") or {}, - composite=payload.get("composite") or {}, - concrete_placing=payload.get("concrete_placing") or {}, - pipe=payload.get("pipe") or {}, - unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, - ) - - -def composite_quantities( - structure: dict[str, Any], - composite: dict[str, Any], - mapping: WorkItemMapping, -) -> tuple[list[dict[str, Any]], list[str]]: - """묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유). - - ⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로 - 못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다. - ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. - 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. - """ - # ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면 - # **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다. - if not (structure.get("components") or []): - note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" - return [], [{"code": None, "reason": note}] - - amounts: dict[str, tuple[float, str]] = {} - for component in structure.get("components") or []: - name = str(component.get("name") or "").strip() - amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) - kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) - - parts: list[dict[str, Any]] = [] - missing: list[str] = [] - for spec in composite.get("parts") or []: - if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다 - parts.append({"code": str(spec)}) - continue - sources = list(spec.get("from_components") or []) - found = [name for name in sources if name in amounts] - total = sum(amounts[name][0] for name in found) - if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": - total *= kg_to_ton - kinds = { - component.get("basis_kind") - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in found - } - suffix = spec.get("kind_suffix") - entry: dict[str, Any] = { - "code": spec.get("code"), - "name": spec.get("name"), - "unit": spec.get("unit"), - "quantity": total if found else None, - # 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다. - "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), - "from_components": sources, - } - if spec.get("incomplete_note"): - # ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다. - # 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다. - entry["incomplete_note"] = spec["incomplete_note"] - if suffix == "euroform_type": - kind, why = euroform_type(str(structure.get("type_id") or "")) - entry["kind"] = normalize_kind_key(kind) if kind else None - entry["kind_label"] = kind # 원문 문구 그대로 - entry["kind_basis"] = why - if kind: - entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}" - else: - entry["not_ready"] = True - entry["why"] = why - missing.append({"code": spec.get("code"), "reason": why}) - if suffix == "rebar_complexity": - # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. - complexity, why = rebar_complexity( - str(structure.get("type_id") or ""), structure.get("options") or {} - ) - entry["kind"] = normalize_kind_key(complexity) if complexity else None - entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함) - entry["kind_basis"] = why - if complexity: - entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}" - else: - entry["not_ready"] = True - entry["why"] = why - missing.append({"code": spec.get("code"), "reason": why}) - if spec.get("not_ready") or not found: - entry["not_ready"] = True - entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") - # ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다. - missing.append({"code": spec.get("code"), "reason": entry["why"]}) - parts.append(entry) - return parts, missing - - -def structure_kind(structure: dict[str, Any]) -> str: - """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** - - 사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이 - 실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다. - """ - for component in structure.get("components") or []: - name = str(component.get("name") or "").strip() - if any(name.startswith(prefix) for prefix in REBAR_PREFIXES): - return "철근구조물" - return "무근구조물" - - -def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]: - """(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다.""" - table = mapping.concrete_placing or {} - codes = table.get("method_codes") or {} - default = str(table.get("default_method") or "") - if method in codes: - return codes[method], False - return codes.get(default), True - - -def load_rebar_table(path: Path | None = None) -> dict[str, Any]: - """철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다.""" - target = path - if target is None: - files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else [] - target = files[-1] if files else None - if target is None or not target.is_file(): - return {} - return json.loads(target.read_text(encoding="utf-8")) - - -def rebar_complexity( - type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None -) -> tuple[str | None, str]: - """(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`. - - 품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**· - 교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」. - 사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.** - """ - found = table if table is not None else load_rebar_table() - form = options.get("form") - fallback: dict[str, Any] | None = None - for row in found.get("form_map") or []: - if row.get("type_id") != type_id: - continue - if "form" not in row: - fallback = row - continue - if row.get("form") == form: - if row.get("class"): - return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」" - return None, str(row.get("why") or "원문 예시에 없음") - if fallback and fallback.get("class"): - return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" - from B08_Quantity.B08_Quantity_Wording import type_label - - detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" - return None, ( - f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 " - "철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다" - ) - - -def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: - """유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. - - 「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래 - (12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다. - - ⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고 - 1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다. - """ - from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table - - found = table if table is not None else load_formwork_table().euroform_type - for row in (found or {}).get("type_map") or []: - if row.get("type_id") == type_id and row.get("class"): - return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」" - from B08_Quantity.B08_Quantity_Wording import type_label - - return None, ( - f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 " - "정하지 못했습니다 — 임의로 고르지 않습니다" - ) - - -def _spec_detail(structure: dict[str, Any]) -> str: - """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" - parts: list[str] = [] - height = structure.get("height_m") - length = structure.get("length_m") - if height: - parts.append(f"H={height:g}") - if length: - parts.append(f"L={length:g}m") - return "·".join(parts) - - -def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: - """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. - - 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. - 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. - """ - if ground is None or ground == "토사": - return ground, "" - method = methods.get(ground) - mapped = METHOD_TO_GROUND.get(method or "") - if mapped: - return mapped, "" - return None, NOTE_METHOD_MISSING - - -def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: - """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. - - ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, - **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 - `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — - 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. - ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. - """ - declared = str((entry or {}).get("basis_unit") or "") - if not declared or not unit: - return None - if normalize_unit(unit) == normalize_unit(declared): - return None - return ( - declared, - str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), - str((entry or {}).get("mismatch_reason") or ""), - ) - - -def _earthwork_rows( - summary_table: dict[str, Any], - mapping: WorkItemMapping, - methods: dict[str, str | None], -) -> tuple[list[dict[str, Any]], list[str]]: - """토공집계표 줄을 내역 줄로 옮긴다. - - ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. - 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. - """ - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for row in summary_table.get("rows") or []: - group = str(row.get("group") or "") - if not group: - continue - ground = row.get("item") or None - origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK - is_subtotal = group in SUBTOTAL_GROUPS - lookup_ground, method_note = _mapping_ground(ground, methods) - entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None - code = (entry or {}).get("work_item_code") - # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). - # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. - unit = str(row.get("unit") or "㎥") - amount = float(row.get("amount") or 0.0) - mismatch = _basis_mismatch(entry, unit) if code else None - spec_detail = "" - if mismatch is not None: - spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" - unit, amount = mismatch[0], 0.0 - if code is None and not is_subtotal: - label = f"{group}({ground})" if ground else group - unmatched.append(f"{label} — {method_note}" if method_note else label) - rows.append( - { - "work_item_code": code, - "name": group, - "spec": str(row.get("spec") or ""), - "unit": unit, - "quantity": amount, - # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. - "quantity_gross": row.get("amount_gross"), - "application_ratio_pct": row.get("application_ratio_pct"), - # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. - "application_ratio_breakdown": row.get("application_ratio_breakdown"), - "quantity_breakdown": row.get("quantity_breakdown"), - "ground_class": ground, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "spec_detail": spec_detail, - "composite_parts": None, - "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": "", - # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. - "blocked_kind": mismatch[1] if mismatch else None, - "blocked_reason": mismatch[2] if mismatch else "", - "composite_not_ready": None, - # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. - "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, - "excavation_method": methods.get(ground) if ground else None, - "in_bill_reason": "집계 합계 줄 — 검산용" - if is_subtotal - else str(row.get("note") or ""), - "origin": origin, - } - ) - return rows, unmatched - - -def _haul_rows( - haul_table: dict[str, Any], mapping: WorkItemMapping -) -> tuple[list[dict[str, Any]], list[str]]: - """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for row in haul_table.get("rows") or []: - equipment = str(row.get("equipment") or "") - entry = mapping.for_haul(equipment) or {} - code = entry.get("work_item_code") - in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) - if code is None and in_bill: - unmatched.append(f"운반({equipment})") - rows.append( - { - "work_item_code": code, - "name": f"{equipment} 운반", - "spec": str(row.get("ground") or ""), - "unit": "㎥", - "quantity": float(row.get("volume_m3") or 0.0), - # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": row.get("ground") or None, - "haul_distance_m": float(row.get("average_distance_m") or 0.0), - "haul_equipment": equipment, - "excavation_method": None, - "station_from": None, - "station_to": None, - "spec_detail": "", - "composite_parts": None, - "structure_kind": None, - # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": "", - "blocked_kind": None, - "blocked_reason": "", - "composite_not_ready": None, - "in_bill": in_bill, - "in_bill_reason": str(entry.get("reason") or ""), - "origin": ORIGIN_HAUL, - } - ) - return rows, unmatched - - -def blocked_of( - structure: dict[str, Any], class_basis: str = "", has_code: bool = False -) -> tuple[str | None, str]: - """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. - - ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. - 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. - - ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). - B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 - **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 - 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. - **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** - """ - notes = [str(note) for note in structure.get("notes") or []] - if has_code and float(structure.get("length_m") or 0.0) > 0: - return None, "" - if structure.get("components"): - # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). - if class_basis and "입력되지 않았습니다" in class_basis: - return BLOCKED_INPUT_MISSING, class_basis - return None, "" - for note in notes: - if "입력되지 않았습니다" in note: - return BLOCKED_INPUT_MISSING, note - if "자료에 없습니다" in note or "표준 물량 자료" in note: - return BLOCKED_UNIT_DATA_MISSING, note - if "산출식이 아직 없습니다" in note: - return BLOCKED_FORMULA_MISSING, note - if notes: - return BLOCKED_UNIT_DATA_MISSING, notes[0] - return None, "" - - -def _component_billing( - structure: dict[str, Any], entry: dict[str, Any] -) -> tuple[str, float] | None: - """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. - - ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × - √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. - ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 - 집는다. - """ - name = str(entry.get("billing_component") or "") - if not name: - return None - for component in structure.get("components") or []: - if str(component.get("name") or "") != name: - continue - unit = str(component.get("unit") or "") - amount = float(component.get("amount") or 0.0) - if unit and amount > 0: - return unit, amount - return None - - -def _structure_rows( - unit_quantity_table: dict[str, Any], mapping: WorkItemMapping -) -> tuple[list[dict[str, Any]], list[str]]: - """구조물 줄 — **작업 공종 하나**로 선다. - - ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, - 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). - """ - rows: list[dict[str, Any]] = [] - unmatched: list[str] = [] - for structure in unit_quantity_table.get("structures") or []: - type_id = str(structure.get("type_id") or "") - entry = mapping.for_structure(type_id) or {} - code = entry.get("work_item_code") - # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — - # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). - class_key: str | None = None - class_basis = "" - if entry.get("class_from") == "bond": - bond = str((structure.get("options") or {}).get("bond") or "").strip() - bond_codes = entry.get("bond_codes") or {} - if bond in bond_codes: - # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. - code = bond_codes[bond] - class_key = bond - class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" - else: - class_basis = ( - "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " - "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" - ) - if code and entry.get("class_from") == "back_length": - class_key, class_basis = masonry_class(structure.get("options") or {}) - - # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 - # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 - # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. - billing = _component_billing(structure, entry) - composite = mapping.composite_for(type_id) if code is None else None - kind = structure_kind(structure) if composite else None - parts: list[dict[str, Any]] | None = None - parts_missing: list[dict[str, Any]] = [] - if composite: - parts, parts_missing = composite_quantities(structure, composite, mapping) - # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — - # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. - counts_by_component = bool(entry.get("billing_component")) - blocked_kind, blocked_reason = blocked_of( - structure, - class_basis, - has_code=bool(code) and (billing is not None or not counts_by_component), - ) - # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. - variant_axis = str(entry.get("variant_axis") or "") or None - variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None - secondary_axes = [ - {"axis": axis, "value": (structure.get("options") or {}).get(axis)} - for axis in entry.get("secondary_axes") or [] - ] - if code is None and composite is None: - unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") - elif entry.get("class_from") in ("back_length", "bond") and class_key is None: - unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") - length = float(structure.get("length_m") or 0.0) - # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — - # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). - # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. - if billing is not None: - bill_unit, bill_quantity = billing - elif counts_by_component: - # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 - # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). - bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 - elif structure.get("billing_unit"): - bill_unit = str(structure["billing_unit"]) - bill_quantity = float(structure.get("billing_quantity") or 0.0) - else: - bill_unit, bill_quantity = "m", length - rows.append( - { - "work_item_code": code, - "name": str(structure.get("name") or type_id), - "spec": _spec_detail(structure), - "unit": bill_unit, - "quantity": bill_quantity, - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": structure.get("start_m"), - "station_to": structure.get("end_m"), - "excavation_method": None, - "spec_detail": _spec_detail(structure), - # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. - "composite_parts": parts, - # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. - "structure_kind": kind, - # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. - "blocked_kind": blocked_kind, - "blocked_reason": blocked_reason, - # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. - # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). - # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 - # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 - # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. - "variant_axis": variant_axis, - "variant_value": variant_value, - # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. - # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). - "secondary_axes": secondary_axes or None, - "spec_class": class_key, - "spec_class_basis": class_basis, - # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. - "composite_not_ready": parts_missing or None, - "in_bill": True, - "in_bill_reason": (composite or {}).get("why", ""), - "origin": ORIGIN_STRUCTURE, - } - ) - return rows, unmatched - - -#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. -#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. -#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 -#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. -#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) -PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) - - -def _placing_rows( - unit_quantity_table: dict[str, Any], - mapping: WorkItemMapping, - method: str | None, -) -> tuple[list[dict[str, Any]], list[str]]: - """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. - - ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 - 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 - `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. - ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. - - ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — - 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). - """ - code, used_default = placing_code(mapping, method) - if code is None: - return [], [] - buckets: dict[str, float] = {} - for structure in unit_quantity_table.get("structures") or []: - # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** - # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` - # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. - # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — - # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** - # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. - if mapping.composite_for(str(structure.get("type_id") or "")): - continue - volume = sum( - float(component.get("amount") or 0.0) - for component in structure.get("components") or [] - if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES - and component.get("unit") == "㎥" - ) - if volume <= 0: - continue - buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume - rows = [ - { - "work_item_code": code, - "name": "콘크리트 타설", - "spec": kind, - "unit": "㎥", - "quantity": volume, - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "excavation_method": None, - "spec_detail": kind, - "composite_parts": None, - "structure_kind": kind, - "blocked_kind": None, - "blocked_reason": "", - "variant_axis": "structure_kind", - "variant_value": kind, - "secondary_axes": None, - "spec_class": kind, - "spec_class_basis": ( - "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" - ), - "composite_not_ready": None, - "in_bill": True, - "in_bill_reason": "", - "origin": ORIGIN_STRUCTURE, - } - for kind, volume in sorted(buckets.items()) - ] - notes: list[str] = [] - if rows and used_default: - notes.append( - "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " - "산출 조건에서 정하면 이 공종의 단가가 달라집니다" - ) - return rows, notes - - -def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: - """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. - - ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). - 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. - 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — - **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** - - ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 - 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. - """ - rows: list[dict[str, Any]] = [] - for row in preparation_table.get("rows") or []: - status = str(row.get("status") or "") - amount = row.get("amount") - ready = status == PREP_READY and amount is not None - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": str(row.get("item") or ""), - "spec": str(row.get("group") or ""), - "unit": str(row.get("unit") or ""), - "quantity": float(amount or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": None, - "station_to": None, - "excavation_method": None, - "spec_detail": str(row.get("group") or ""), - "composite_parts": None, - "structure_kind": None, - # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. - "blocked_kind": None if ready else _prep_blocked_kind(status), - "blocked_reason": "" if ready else str(row.get("reason") or status), - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("reason") or ""), - "composite_not_ready": None, - # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("reason") or status), - "origin": ORIGIN_PREPARATION, - } - ) - return rows - - -def _prep_blocked_kind(status: str) -> str | None: - """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" - if status == PREP_PENDING: - return BLOCKED_INPUT_MISSING - if status == PREP_COUNTED_ELSEWHERE: - # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. - return None - if status == PREP_NOT_APPLICABLE: - return None - return BLOCKED_UNIT_DATA_MISSING - - -def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: - """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). - - ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 - (B09 ㉡ 가드와 같은 자리). - """ - rows: list[dict[str, Any]] = [] - for row in pipe_table.get("rows") or []: - ready = bool(row.get("in_bill")) - rows.append( - { - "work_item_code": row.get("work_item_code"), - "name": f"배수관({row.get('kind')})", - "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "unit": str(row.get("unit") or "m"), - "quantity": float(row.get("quantity") or 0.0), - "quantity_gross": None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - "station_from": row.get("chainage_m"), - "station_to": row.get("chainage_m"), - "excavation_method": None, - "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": row.get("blocked_kind"), - "blocked_reason": str(row.get("blocked_reason") or ""), - # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. - "variant_axis": row.get("variant_axis"), - "variant_value": row.get("variant_value"), - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": str(row.get("blocked_reason") or ""), - "composite_not_ready": None, - "in_bill": ready, - "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), - "origin": ORIGIN_PIPE, - } - ) - return rows - - -def _length_rows( - length_table: list[dict[str, Any]], mapping: WorkItemMapping -) -> list[dict[str, Any]]: - """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). - - ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** - 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. - 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 - **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). - - ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, - C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. - 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. - - ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** - """ - rows: list[dict[str, Any]] = [] - for entry in length_table or []: - type_id = str(entry.get("type_id") or "") - found = mapping.for_structure(type_id) - code = (found or {}).get("work_item_code") - length = float(entry.get("length_m") or 0.0) - raw = float(entry.get("raw_length_m") or length) - # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 - # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). - # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. - spans = [ - span - for span in (entry.get("spans") or []) - if span.get("start_m") is not None and span.get("end_m") is not None - ] - span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) - note = f"구간 {span_note}" if span_note else "" - if abs(raw - length) > 1e-9: - 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" - note = f"{note} · {겹침}" if note else 겹침 - # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 - # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). - reason = "" - if code is None: - reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" - elif length <= 0: - reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" - rows.append( - { - "work_item_code": code, - "name": str(entry.get("name") or type_id), - "spec": f"{entry.get('count')}개소", - "unit": "m", - "quantity": length, - "quantity_gross": raw if note else None, - "application_ratio_pct": None, - "application_ratio_breakdown": None, - "quantity_breakdown": None, - "ground_class": None, - "haul_distance_m": None, - "haul_equipment": None, - # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. - "station_from": spans[0]["start_m"] if spans else None, - "station_to": spans[-1]["end_m"] if spans else None, - "excavation_method": None, - "spec_detail": f"{entry.get('count')}개소", - "composite_parts": None, - "structure_kind": None, - "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, - "blocked_reason": reason, - "variant_axis": None, - "variant_value": None, - "secondary_axes": None, - "spec_class": None, - "spec_class_basis": note, - "composite_not_ready": None, - "in_bill": bool(code and length > 0), - "in_bill_reason": "" if (code and length > 0) else reason, - "origin": ORIGIN_STRUCTURE, - } - ) - return rows - - -def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: - """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" - rows: list[dict[str, Any]] = [] - for row in material_table.get("rows") or []: - rows.append( - { - "material_name": row.get("name"), - "spec": row.get("spec") or "", - "unit": row.get("unit"), - "net_amount": row.get("net_amount"), - "total_amount": row.get("total_amount"), - "surcharge_pct": row.get("surcharge_pct"), - "surcharge_note": row.get("note") or "", - "supply_type": row.get("supply"), - "install_by": row.get("install_by"), - "source_structure": row.get("sources") or [], - } - ) - return rows - def build_handoff( *, diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py new file mode 100644 index 00000000..7bdc944e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -0,0 +1,410 @@ +"""공종 매핑과 갈래표 — **어느 품셈 공종에 잇나** (`Engine_Handoff` 에서 갈라냄). + +⚠ **왜 갈랐나** — `Engine_Handoff.py` 가 1,279줄로 700줄 제한의 두 배였다(2026-09-08). + **인계 계약(줄의 모양)은 하나도 안 바뀐다** — 파일만 가른다. 부르는 쪽은 종전대로 + `B08_Quantity_Engine_Handoff` 에서 그대로 가져다 쓴다(그쪽이 다시 내보낸다). + +여기 있는 것 — 매핑표 읽기 · `WorkItemMapping` · 갈래표(돌쌓기·철근·유로폼·목재공작물) · +묶음 전개 · 줄의 출처·막힘 갈래 이름. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" +DATASET_PREFIX = "work_item_mapping_" + +#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이 +#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다. +REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" +REBAR_PREFIX = "rebar_complexity_" + +#: 돌쌓기 규격 갈래표 — 저장 제원 값으로 자동 판정한다(사람이 고르는 값이 아니다). +MASONRY_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +MASONRY_PREFIX = "masonry_class_" + + +#: 목재공작물 구조 갈래표 — 품셈 13-13-1 [주]③ 이 **재료 구성**으로 가른다. +TIMBER_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_timber" +TIMBER_PREFIX = "timber_structure_class_" + + +def load_timber_table(path: Path | None = None) -> dict[str, Any]: + """목재공작물 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(TIMBER_DIR.glob(TIMBER_PREFIX + "*.json")) if TIMBER_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def timber_class(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str, bool]: + """(갈래, 근거, 잠정인가). **잠정이면 그 사실을 숨기지 않는다.** + + ⚠ 갈래를 고르되 **드러낸다** — 임의로 고르고 조용히 넘어가면 미결을 숨기는 것이다. + 밑수 1㎥ 는 **목재 채적**이라 「1㎥에 건축목공 17인」이 말이 된다(원문 [주]③). + """ + found = table if table is not None else load_timber_table() + for row in (found or {}).get("type_map") or []: + if row.get("type_id") == type_id and row.get("class"): + basis = f"품셈 13-13-1 [주]③ 「{row.get('matched')}」" + if row.get("provisional"): + basis += f" · ⚠ 잠정 — {row.get('compare', '')}" + return str(row["class"]), basis, bool(row.get("provisional")) + return None, f"품셈 13-13-1 [주]③ 예시에 없는 공작물({type_id}) — 임의로 고르지 않음", False + + +def load_masonry_table(path: Path | None = None) -> dict[str, Any]: + """돌쌓기 갈래표. 파일이 없으면 빈 표 — 갈래가 안 붙고 그대로 드러난다.""" + target = path + if target is None: + files = sorted(MASONRY_DIR.glob(MASONRY_PREFIX + "*.json")) if MASONRY_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def masonry_class( + options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """돌쌓기 갈래 — 저장 뒷길이로 **「…㎝ 이하」 구간**을 고른다. + + ⚠ 저장 선택지(25·30·35·45·55·60·75)와 단가 갈래(35·55·75 이하)는 축이 다르다. + **저장값 이상인 첫 경계**를 고르는 것이 「이하」 구간의 뜻이다. + """ + found = table if table is not None else load_masonry_table() + spec = (found or {}).get("back_length") or {} + from B08_Quantity.B08_Quantity_Wording import option_missing + + option_key = str(spec.get("option_key") or "back_len_cm") + raw = options.get(option_key) + if raw is None: + # ⚠ 「없다」만 말하지 않는다 — **어디서 채우면 단가가 붙는지**까지. + # 이름은 부르는 쪽(`unmatched`)이 이미 앞에 붙이므로 여기서는 칸 이름만 말한다. + return None, option_missing(option_key) + " (단가 갈래를 못 고름)" + try: + value = float(raw) + except (TypeError, ValueError): + return None, f"뒷길이 값을 못 읽음({raw!r})" + for row in spec.get("classes") or []: + if value <= float(row["max_cm"]): + return str(row["key"]), f"뒷길이 {value:g}㎝ → 품셈 13-4 「{row['key']}」 구간" + return None, f"뒷길이 {value:g}㎝ 를 덮는 갈래가 표에 없음" + + +def normalize_kind_key(label: str) -> str: + """갈래 키 — **내부 공백만** 지운다 (2026-09-07 두 창 확정). + + 원문 표는 「보 통」처럼 자간 공백이 들어 있어 그대로 쓰면 양쪽이 안 맞는다. + ⚠ **다른 글자는 손대지 않는다** — 정규화를 넓히면 오늘 아홉 번 겪은 그 병을 + 여기서 새로 만든다. 원문 문구는 버리지 않고 `label` 로 함께 싣는다. + """ + return "".join(str(label).split()) + + +#: 줄이 어디서 왔나 — 되짚을 때 쓴다. +ORIGIN_EARTHWORK = "earthwork" +ORIGIN_STRUCTURE = "structure" +ORIGIN_SLOPE = "slope" +ORIGIN_HAUL = "haul" +ORIGIN_PREPARATION = "preparation" +ORIGIN_PIPE = "pipe" + +#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에 +#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남). +METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"} +NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름" + +#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 — +#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이 +#: 아니라 공종 이름이라 성분 목록에는 안 온다. +REBAR_PREFIXES = ("이형철근", "원형철근", "철근") + +#: 줄이 왜 막혔나 — **받는 쪽이 「사용자가 입력하면 풀리는 것」과 「우리가 만들어야 하는 것」을 +#: 화면에서 갈라야** 한다(2026-09-07 3자 확정). 8-27 표에서 이미 가른 그 축이다. +BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림 +BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음 +BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음 + +#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) + +#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. +SUBTOTAL_GROUPS = frozenset({"보정량계"}) + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class WorkItemMapping: + """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" + + effective_date: str = "" + earthwork: list[dict[str, Any]] = field(default_factory=list) + haul: list[dict[str, Any]] = field(default_factory=list) + structure: list[dict[str, Any]] = field(default_factory=list) + pending_user: dict[str, Any] = field(default_factory=dict) + composite: dict[str, Any] = field(default_factory=dict) + concrete_placing: dict[str, Any] = field(default_factory=dict) + unit_conversion: dict[str, Any] = field(default_factory=dict) + #: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다. + pipe: dict[str, Any] = field(default_factory=dict) + + def declared_units(self) -> dict[str, str]: + """공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다. + + ⚠ 마스터가 못 채운 자리를 메우는 값이다(층따기 9-18 처럼 공식 [주]로만 단위가 + 밝혀지는 공종). 여기 적을 때는 **어느 원문 줄에서 읽었는지**(`basis_source`)를 + 함께 남길 것 — 근거 없는 단위가 대조의 기준이 되면 안 된다. + """ + found: dict[str, str] = {} + for row in (*self.earthwork, *self.haul, *self.structure): + code, unit = row.get("work_item_code"), row.get("basis_unit") + if code and unit: + found[str(code)] = str(unit) + return found + + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 + """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" + exact = [ + row + for row in self.earthwork + if row.get("group") == group and row.get("ground") == ground + ] + if exact: + return exact[0] + # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. + loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] + return loose[0] if loose else None + + def for_haul(self, equipment: str) -> dict[str, Any] | None: + for row in self.haul: + if row.get("equipment") == equipment: + return row + return None + + def for_structure(self, type_id: str) -> dict[str, Any] | None: + for row in self.structure: + if row.get("type_id") == type_id: + return row + return None + + def composite_for(self, type_id: str) -> dict[str, Any] | None: + """품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가. + + 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다. + """ + for row in self.composite.get("items") or []: + if row.get("type_id") == type_id: + return row + return None + + +def load_mapping(path: Path | None = None) -> WorkItemMapping: + """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return WorkItemMapping() + payload = json.loads(target.read_text(encoding="utf-8")) + return WorkItemMapping( + effective_date=str(payload.get("effective_date") or ""), + earthwork=list(payload.get("earthwork") or []), + haul=list(payload.get("haul") or []), + structure=list(payload.get("structure") or []), + pending_user=payload.get("pending_user") or {}, + composite=payload.get("composite") or {}, + concrete_placing=payload.get("concrete_placing") or {}, + pipe=payload.get("pipe") or {}, + unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, + ) + + +def composite_quantities( + structure: dict[str, Any], + composite: dict[str, Any], + mapping: WorkItemMapping, +) -> tuple[list[dict[str, Any]], list[str]]: + """묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유). + + ⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로 + 못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다. + ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. + 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. + """ + # ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면 + # **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다. + if not (structure.get("components") or []): + note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음" + return [], [{"code": None, "reason": note}] + + amounts: dict[str, tuple[float, str]] = {} + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) + kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) + + parts: list[dict[str, Any]] = [] + missing: list[str] = [] + for spec in composite.get("parts") or []: + if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다 + parts.append({"code": str(spec)}) + continue + sources = list(spec.get("from_components") or []) + found = [name for name in sources if name in amounts] + total = sum(amounts[name][0] for name in found) + if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": + total *= kg_to_ton + kinds = { + component.get("basis_kind") + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in found + } + suffix = spec.get("kind_suffix") + entry: dict[str, Any] = { + "code": spec.get("code"), + "name": spec.get("name"), + "unit": spec.get("unit"), + "quantity": total if found else None, + # 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다. + "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), + "from_components": sources, + } + if spec.get("incomplete_note"): + # ⚠ 물량은 섰으나 **일부 몫이 빠진** 조각 — 「못 채움」과 달리 값은 있다. + # 화면·인계 둘 다 그 사실을 알아야 「다 섰다」로 오해하지 않는다. + entry["incomplete_note"] = spec["incomplete_note"] + if suffix == "euroform_type": + kind, why = euroform_type(str(structure.get("type_id") or "")) + entry["kind"] = normalize_kind_key(kind) if kind else None + entry["kind_label"] = kind # 원문 문구 그대로 + entry["kind_basis"] = why + if kind: + entry["code"] = f"{spec.get('code')}#{normalize_kind_key(kind)}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) + if suffix == "rebar_complexity": + # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. + complexity, why = rebar_complexity( + str(structure.get("type_id") or ""), structure.get("options") or {} + ) + entry["kind"] = normalize_kind_key(complexity) if complexity else None + entry["kind_label"] = complexity # 원문 문구 그대로(자간 공백 포함) + entry["kind_basis"] = why + if complexity: + entry["code"] = f"{spec.get('code')}#{normalize_kind_key(complexity)}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append({"code": spec.get("code"), "reason": why}) + if spec.get("not_ready") or not found: + entry["not_ready"] = True + entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") + # ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다. + missing.append({"code": spec.get("code"), "reason": entry["why"]}) + parts.append(entry) + return parts, missing + + +def structure_kind(structure: dict[str, Any]) -> str: + """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** + + 사람이 고르는 값이 아니다(2026-09-07 3자 확정). 옹벽 관측 원단위에 `D13`·`D16` 이 + 실려 있으므로 철근구조물로 자동으로 선다. 소형구조물 판정 기준은 아직 없다. + """ + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + if any(name.startswith(prefix) for prefix in REBAR_PREFIXES): + return "철근구조물" + return "무근구조물" + + +def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | None, bool]: + """(타설 공종코드, 기본값을 쓴 것인가). 모르는 방식이면 기본으로 떨어지되 그 사실을 알린다.""" + table = mapping.concrete_placing or {} + codes = table.get("method_codes") or {} + default = str(table.get("default_method") or "") + if method in codes: + return codes[method], False + return codes.get(default), True + + +def load_rebar_table(path: Path | None = None) -> dict[str, Any]: + """철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다.""" + target = path + if target is None: + files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def rebar_complexity( + type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`. + + 품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**· + 교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」. + 사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.** + """ + found = table if table is not None else load_rebar_table() + form = options.get("form") + fallback: dict[str, Any] | None = None + for row in found.get("form_map") or []: + if row.get("type_id") != type_id: + continue + if "form" not in row: + fallback = row + continue + if row.get("form") == form: + if row.get("class"): + return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}」" + return None, str(row.get("why") or "원문 예시에 없음") + if fallback and fallback.get("class"): + return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}」" + from B08_Quantity.B08_Quantity_Wording import type_label + + detail = f"({form})" if form else "(형식이 아직 입력되지 않음)" + return None, ( + f"{type_label(type_id)} {detail} 는 품셈 12-3 [주]① 예시에 없어 " + "철근 갈래를 정하지 못했습니다 — 임의로 고르지 않습니다" + ) + + +def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]: + """유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. + + 「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래 + (12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다. + + ⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고 + 1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다. + """ + from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table + + found = table if table is not None else load_formwork_table().euroform_type + for row in (found or {}).get("type_map") or []: + if row.get("type_id") == type_id and row.get("class"): + return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」" + from B08_Quantity.B08_Quantity_Wording import type_label + + return None, ( + f"{type_label(type_id)} 는 품셈 12-38-3 [주]④ 예시에 없어 유로폼 유형을 " + "정하지 못했습니다 — 임의로 고르지 않습니다" + ) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py new file mode 100644 index 00000000..37db599b --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Rows.py @@ -0,0 +1,702 @@ +"""인계 줄을 만드는 자리 — 토공·운반·구조물·준비공·배수관·연장·타설 (`Engine_Handoff` 에서 갈라냄). + +⚠ **왜 갈랐나** — 위와 같다(700줄 제한). **줄의 모양은 하나도 안 바뀐다.** +⚠ **줄 빌더가 여덟이라 칸을 하나 늘리면 여덟 곳을 함께 고쳐야 한다** — 계약 시험 + (`tmp/tests/test_b08_handoff_contract.py`)이 「모든 줄이 같은 칸을 갖는가」로 그것을 지킨다. +""" + +from __future__ import annotations + +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_code +from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( + BLOCKED_FORMULA_MISSING, + BLOCKED_INPUT_MISSING, + BLOCKED_UNIT_DATA_MISSING, + METHOD_TO_GROUND, + NOTE_METHOD_MISSING, + ORIGIN_EARTHWORK, + ORIGIN_HAUL, + ORIGIN_PIPE, + ORIGIN_PREPARATION, + ORIGIN_SLOPE, + ORIGIN_STRUCTURE, + SLOPE_GROUPS, + SUBTOTAL_GROUPS, + WorkItemMapping, + composite_quantities, + masonry_class, + placing_code, + structure_kind, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import ( + STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE, +) +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING +from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY +from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label + + +def _spec_detail(structure: dict[str, Any]) -> str: + """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" + parts: list[str] = [] + height = structure.get("height_m") + length = structure.get("length_m") + if height: + parts.append(f"H={height:g}") + if length: + parts.append(f"L={length:g}m") + return "·".join(parts) + + +def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]: + """갈래 이름을 **매핑표가 아는 이름**으로 바꾼다. + + 「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다. + 안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다. + """ + if ground is None or ground == "토사": + return ground, "" + method = methods.get(ground) + mapped = METHOD_TO_GROUND.get(method or "") + if mapped: + return mapped, "" + return None, NOTE_METHOD_MISSING + + +def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str, str] | None: + """(품셈 밑수, 막힘 갈래, 사유) — 매핑이 밝힌 밑수와 우리 단위가 **뜻이 다를 때만**. + + ⚠ **왜 매핑이 밝히나** — 마스터 `basis_unit` 은 절 머리의 「(단위: …)」에서 오는데, + **공식으로만 단위가 밝혀지는 공종**은 그 자리가 비어 있다(층따기 9-18 은 [주]의 + `Q1 = … = ㎥/시간` 이 유일한 단서). 마스터가 비면 밑수 대조가 조용히 통과한다 — + 그래서 **원문에서 읽은 밑수를 매핑에 적어** 대조가 서게 한다. + ⚠ **환산하지 않는다.** ㎡ 를 ㎥ 로 바꾸려면 층따기 단의 높이·폭을 지어내야 한다. + """ + declared = str((entry or {}).get("basis_unit") or "") + if not declared or not unit: + return None + if normalize_unit(unit) == normalize_unit(declared): + return None + return ( + declared, + str((entry or {}).get("mismatch_kind") or BLOCKED_UNIT_DATA_MISSING), + str((entry or {}).get("mismatch_reason") or ""), + ) + + +def _earthwork_rows( + summary_table: dict[str, Any], + mapping: WorkItemMapping, + methods: dict[str, str | None], +) -> tuple[list[dict[str, Any]], list[str]]: + """토공집계표 줄을 내역 줄로 옮긴다. + + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. + 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in summary_table.get("rows") or []: + group = str(row.get("group") or "") + if not group: + continue + ground = row.get("item") or None + origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK + is_subtotal = group in SUBTOTAL_GROUPS + lookup_ground, method_note = _mapping_ground(ground, methods) + entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None + code = (entry or {}).get("work_item_code") + # ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18). + # 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다. + unit = str(row.get("unit") or "㎥") + amount = float(row.get("amount") or 0.0) + mismatch = _basis_mismatch(entry, unit) if code else None + spec_detail = "" + if mismatch is not None: + spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})" + unit, amount = mismatch[0], 0.0 + if code is None and not is_subtotal: + label = f"{group}({ground})" if ground else group + unmatched.append(f"{label} — {method_note}" if method_note else label) + rows.append( + { + "work_item_code": code, + "name": group, + "spec": str(row.get("spec") or ""), + "unit": unit, + "quantity": amount, + # 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다. + "quantity_gross": row.get("amount_gross"), + "application_ratio_pct": row.get("application_ratio_pct"), + # 율이 부분마다 다른 줄 — 받는 쪽이 문장을 안 뜯게 칸으로 준다. + "application_ratio_breakdown": row.get("application_ratio_breakdown"), + "quantity_breakdown": row.get("quantity_breakdown"), + "ground_class": ground, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "spec_detail": spec_detail, + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + # 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다. + "blocked_kind": mismatch[1] if mismatch else None, + "blocked_reason": mismatch[2] if mismatch else "", + "composite_not_ready": None, + # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None, + "excavation_method": methods.get(ground) if ground else None, + "in_bill_reason": "집계 합계 줄 — 검산용" + if is_subtotal + else str(row.get("note") or ""), + "origin": origin, + } + ) + return rows, unmatched + + +def _haul_rows( + haul_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in haul_table.get("rows") or []: + equipment = str(row.get("equipment") or "") + entry = mapping.for_haul(equipment) or {} + code = entry.get("work_item_code") + in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) + if code is None and in_bill: + unmatched.append(f"운반({equipment})") + rows.append( + { + "work_item_code": code, + "name": f"{equipment} 운반", + "spec": str(row.get("ground") or ""), + "unit": "㎥", + "quantity": float(row.get("volume_m3") or 0.0), + # 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다). + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": row.get("ground") or None, + "haul_distance_m": float(row.get("average_distance_m") or 0.0), + "haul_equipment": equipment, + "excavation_method": None, + "station_from": None, + "station_to": None, + "spec_detail": "", + "composite_parts": None, + "structure_kind": None, + # 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양). + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": "", + "blocked_kind": None, + "blocked_reason": "", + "composite_not_ready": None, + "in_bill": in_bill, + "in_bill_reason": str(entry.get("reason") or ""), + "origin": ORIGIN_HAUL, + } + ) + return rows, unmatched + + +def blocked_of( + structure: dict[str, Any], class_basis: str = "", has_code: bool = False +) -> tuple[str | None, str]: + """(막힌 갈래, 사유). 안 막혔으면 `(None, "")`. + + ⚠ 사유 문구는 **`B08_Quantity_Wording` 것을 그대로** 쓴다 — 두 벌로 짜면 갈린다. + 전개 알림(`notes`)에 이미 사람 말로 적혀 있으므로 그것을 그대로 옮긴다. + + ⚠⚠ **전개식이 없다고 다 막힌 것이 아니다** (2026-09-08 V-3 에서 드러남). + B군 종단배수(산마루측구 12-9-2 · 소단측구 12-9-3 · 맹암거 12-10)는 품셈 밑수가 + **1 m** 라 **연장이 곧 수량**이다 — 원단위 전개가 필요 없다. 그런데 「성분이 없으면 + 전개식 없음」으로 단정해 B09 가 **「우리가 만들 것」으로 빼 금액이 0** 이었다. + **공종코드가 붙었고 수량이 있으면 막힌 것이 아니다.** + """ + notes = [str(note) for note in structure.get("notes") or []] + if has_code and float(structure.get("length_m") or 0.0) > 0: + return None, "" + if structure.get("components"): + # 물량은 섰는데 **단가 갈래**를 못 고른 자리(돌쌓기 뒷길이 등). + if class_basis and "입력되지 않았습니다" in class_basis: + return BLOCKED_INPUT_MISSING, class_basis + return None, "" + for note in notes: + if "입력되지 않았습니다" in note: + return BLOCKED_INPUT_MISSING, note + if "자료에 없습니다" in note or "표준 물량 자료" in note: + return BLOCKED_UNIT_DATA_MISSING, note + if "산출식이 아직 없습니다" in note: + return BLOCKED_FORMULA_MISSING, note + if notes: + return BLOCKED_UNIT_DATA_MISSING, notes[0] + return None, "" + + +def _component_billing( + structure: dict[str, Any], entry: dict[str, Any] +) -> tuple[str, float] | None: + """(내역 단위, 그 단위로 센 수량) — **전개 성분 하나**에서 가져온다. 없으면 `None`. + + ⚠ **왜 성분에서 가져오나** — 돌쌓기 면적은 이미 전개가 냈다(비탈면적 = 정면적 × + √(1+n²)). 여기서 다시 재면 **같은 식이 두 벌**이 되고 한쪽만 고쳐지는 자리가 된다. + ⚠ **어느 성분인지는 매핑이 말한다** — 단위만 보고 고르면 거푸집 같은 다른 ㎡ 성분을 + 집는다. + """ + name = str(entry.get("billing_component") or "") + if not name: + return None + for component in structure.get("components") or []: + if str(component.get("name") or "") != name: + continue + unit = str(component.get("unit") or "") + amount = float(component.get("amount") or 0.0) + if unit and amount > 0: + return unit, amount + return None + + +def _structure_rows( + unit_quantity_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """구조물 줄 — **작업 공종 하나**로 선다. + + ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, + 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for structure in unit_quantity_table.get("structures") or []: + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + code = entry.get("work_item_code") + # 돌쌓기는 **뒷길이 갈래**로, 큰돌쌓기는 **메/찰**로 단가가 갈린다 — + # 둘 다 저장 제원에서 자동으로 고른다(사용자 칸을 따로 만들지 않는다). + class_key: str | None = None + class_basis = "" + if entry.get("class_from") == "bond": + bond = str((structure.get("options") or {}).get("bond") or "").strip() + bond_codes = entry.get("bond_codes") or {} + if bond in bond_codes: + # 메/찰은 **의미 판정**이라 우리 몫이다 — 공종 자체가 갈린다. + code = bond_codes[bond] + class_key = bond + class_basis = f"쌓기 방식 「{bond}」 → 품셈 13-6 {code.split('-')[-1]}" + else: + class_basis = ( + "큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + "메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다" + ) + if code and entry.get("class_from") == "back_length": + class_key, class_basis = masonry_class(structure.get("options") or {}) + + # ⚠ 품셈 밑수가 「㎡당」인 공종은 **연장으로 세면 안 된다** — 받는 쪽이 ㎡ 단가를 + # m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는 + # 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다. + billing = _component_billing(structure, entry) + composite = mapping.composite_for(type_id) if code is None else None + kind = structure_kind(structure) if composite else None + parts: list[dict[str, Any]] | None = None + parts_missing: list[dict[str, Any]] = [] + if composite: + parts, parts_missing = composite_quantities(structure, composite, mapping) + # ⚠ 매핑이 「이 성분으로 센다」고 했는데 그 성분이 없으면 **연장으로 세지 않는다** — + # 세면 다시 틀린 축으로 금액이 선다. 코드가 없는 것과 같이 보아 사유를 찾는다. + counts_by_component = bool(entry.get("billing_component")) + blocked_kind, blocked_reason = blocked_of( + structure, + class_basis, + has_code=bool(code) and (billing is not None or not counts_by_component), + ) + # 갈래 축과 **저장 제원 원본값**. 가공하지 않는다. + variant_axis = str(entry.get("variant_axis") or "") or None + variant_value = (structure.get("options") or {}).get(variant_axis) if variant_axis else None + secondary_axes = [ + {"axis": axis, "value": (structure.get("options") or {}).get(axis)} + for axis in entry.get("secondary_axes") or [] + ] + if code is None and composite is None: + unmatched.append(f"{wording_type_label(type_id)} — 품셈 공종을 아직 못 이었습니다") + elif entry.get("class_from") in ("back_length", "bond") and class_key is None: + unmatched.append(f"{wording_type_label(type_id)} — {class_basis}") + length = float(structure.get("length_m") or 0.0) + # ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** — + # 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증). + # 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다. + if billing is not None: + bill_unit, bill_quantity = billing + elif counts_by_component: + # ⚠ 물량이 못 섰다 — **연장으로 대신 세지 않는다.** 단위는 품셈 밑수를 그대로 + # 실어 둔다(「0 m」로 내면 받는 쪽이 길이로 읽고 축이 어긋난 채 채워진다). + bill_unit, bill_quantity = unit_for_code(str(code or "")), 0.0 + elif structure.get("billing_unit"): + bill_unit = str(structure["billing_unit"]) + bill_quantity = float(structure.get("billing_quantity") or 0.0) + else: + bill_unit, bill_quantity = "m", length + rows.append( + { + "work_item_code": code, + "name": str(structure.get("name") or type_id), + "spec": _spec_detail(structure), + "unit": bill_unit, + "quantity": bill_quantity, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": structure.get("start_m"), + "station_to": structure.get("end_m"), + "excavation_method": None, + "spec_detail": _spec_detail(structure), + # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. + "composite_parts": parts, + # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. + "structure_kind": kind, + # ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다. + "blocked_kind": blocked_kind, + "blocked_reason": blocked_reason, + # 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다. + # ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경). + # 품셈 원문이 물결표를 섞어 쓴다(`∼` U+223C / `~` U+FF5E). 두 창이 각자 + # 키를 조립하면 **글자 하나로 영영 안 맞는다.** 우리는 **어느 축인지와 + # 저장 원본값**만 보내고, 원문을 읽는 쪽이 그 표기를 흡수한다. + "variant_axis": variant_axis, + "variant_value": variant_value, + # ⚠ 갈래 축이 **둘 이상**인 자리 — 돌쌓기는 뒷길이와 **돌 종류**로 갈린다. + # 여기서도 **저장 원본값만** 싣는다(키 조립은 원문 읽는 쪽 몫). + "secondary_axes": secondary_axes or None, + "spec_class": class_key, + "spec_class_basis": class_basis, + # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. + "composite_not_ready": parts_missing or None, + "in_bill": True, + "in_bill_reason": (composite or {}).get("why", ""), + "origin": ORIGIN_STRUCTURE, + } + ) + return rows, unmatched + + +#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다. +#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다. +#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는 +#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다. +#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.) +PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"}) + + +def _placing_rows( + unit_quantity_table: dict[str, Any], + mapping: WorkItemMapping, + method: str | None, +) -> tuple[list[dict[str, Any]], list[str]]: + """콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다. + + ⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고 + 재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가 + `B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다. + ⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다. + + ⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 — + 사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`). + """ + code, used_default = placing_code(mapping, method) + if code is None: + return [], [] + buckets: dict[str, float] = {} + for structure in unit_quantity_table.get("structures") or []: + # ⚠⚠ **묶음으로 서는 구조물은 건너뛴다 — 그 콘크리트는 묶음 조각이 이미 센다.** + # 옹벽 묶음에 `FP-12-01-01 콘크리트 타설` 조각이 들어 있다(`work_item_mapping` + # 의 `composite`). 여기서 또 세우면 **같은 콘크리트를 두 번** 센다. + # 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 — + # 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.** + # ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다. + if mapping.composite_for(str(structure.get("type_id") or "")): + continue + volume = sum( + float(component.get("amount") or 0.0) + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES + and component.get("unit") == "㎥" + ) + if volume <= 0: + continue + buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume + rows = [ + { + "work_item_code": code, + "name": "콘크리트 타설", + "spec": kind, + "unit": "㎥", + "quantity": volume, + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": kind, + "composite_parts": None, + "structure_kind": kind, + "blocked_kind": None, + "blocked_reason": "", + "variant_axis": "structure_kind", + "variant_value": kind, + "secondary_axes": None, + "spec_class": kind, + "spec_class_basis": ( + "철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정" + ), + "composite_not_ready": None, + "in_bill": True, + "in_bill_reason": "", + "origin": ORIGIN_STRUCTURE, + } + for kind, volume in sorted(buckets.items()) + ] + notes: list[str] = [] + if rows and used_default: + notes.append( + "콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — " + "산출 조건에서 정하면 이 공종의 단가가 달라집니다" + ) + return rows, notes + + +def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]: + """준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다. + + ⚠ **줄을 빼면 「빠졌다는 사실조차 안 보인다」** (2026-09-08 보조 창 제보). + 받는 쪽 화면에서 「내역서에 원래 없는 것」과 「우리가 아직 못 내는 것」이 구별되지 않는다. + 그래서 못 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다 — + **금액은 안 붙되 「무엇이 채워지면 풀리는지」가 함께 간다.** + + ⚠ 이 표가 통째로 안 가고 있었다 — 표토제거(값 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)이 + 화면에는 서는데 인계에는 없었다. 「사유를 실어 달라」는 요청을 보다 드러났다. + """ + rows: list[dict[str, Any]] = [] + for row in preparation_table.get("rows") or []: + status = str(row.get("status") or "") + amount = row.get("amount") + ready = status == PREP_READY and amount is not None + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": str(row.get("item") or ""), + "spec": str(row.get("group") or ""), + "unit": str(row.get("unit") or ""), + "quantity": float(amount or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "excavation_method": None, + "spec_detail": str(row.get("group") or ""), + "composite_parts": None, + "structure_kind": None, + # ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다. + "blocked_kind": None if ready else _prep_blocked_kind(status), + "blocked_reason": "" if ready else str(row.get("reason") or status), + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("reason") or ""), + "composite_not_ready": None, + # 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다. + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("reason") or status), + "origin": ORIGIN_PREPARATION, + } + ) + return rows + + +def _prep_blocked_kind(status: str) -> str | None: + """준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`.""" + if status == PREP_PENDING: + return BLOCKED_INPUT_MISSING + if status == PREP_COUNTED_ELSEWHERE: + # 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다. + return None + if status == PREP_NOT_APPLICABLE: + return None + return BLOCKED_UNIT_DATA_MISSING + + +def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]: + """배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙). + + ⚠ 터파기·되메우기를 붙이지 않는다 — 관 부설과 굴착이 각각 오면 **같은 굴착을 두 번** 센다 + (B09 ㉡ 가드와 같은 자리). + """ + rows: list[dict[str, Any]] = [] + for row in pipe_table.get("rows") or []: + ready = bool(row.get("in_bill")) + rows.append( + { + "work_item_code": row.get("work_item_code"), + "name": f"배수관({row.get('kind')})", + "spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "unit": str(row.get("unit") or "m"), + "quantity": float(row.get("quantity") or 0.0), + "quantity_gross": None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": row.get("chainage_m"), + "station_to": row.get("chainage_m"), + "excavation_method": None, + "spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": row.get("blocked_kind"), + "blocked_reason": str(row.get("blocked_reason") or ""), + # 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫. + "variant_axis": row.get("variant_axis"), + "variant_value": row.get("variant_value"), + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": str(row.get("blocked_reason") or ""), + "composite_not_ready": None, + "in_bill": ready, + "in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""), + "origin": ORIGIN_PIPE, + } + ) + return rows + + +def _length_rows( + length_table: list[dict[str, Any]], mapping: WorkItemMapping +) -> list[dict[str, Any]]: + """B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량). + + ⚠ **왜 구조물별로 안 내나** — `common_util_structure_lengths` 가 **겹친 구간을 합쳐** + 준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 그 구간을 **두 번** 센다. + 그 규칙(겹침 합치기 · 측구 제외 · 관 소관 제외)이 이미 그 함수에 있으므로 + **두 벌로 짜지 않는다**(2026-09-08 랩탑 창 제안, 두 창 합의). + + ⚠ **C군(돌쌓기·옹벽 등)은 여기로 오지 않는다** — 그 함수는 종류별로 뭉쳐 내는데, + C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다. + 실무 내역도 B군은 「산마루측구 40m」 한 줄, C군은 구조물별 줄이다. + + ⚠ 겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.** + """ + rows: list[dict[str, Any]] = [] + for entry in length_table or []: + type_id = str(entry.get("type_id") or "") + found = mapping.for_structure(type_id) + code = (found or {}).get("work_item_code") + length = float(entry.get("length_m") or 0.0) + raw = float(entry.get("raw_length_m") or length) + # 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다 + # (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창). + # ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다. + spans = [ + span + for span in (entry.get("spans") or []) + if span.get("start_m") is not None and span.get("end_m") is not None + ] + span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans) + note = f"구간 {span_note}" if span_note else "" + if abs(raw - length) > 1e-9: + 겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값" + note = f"{note} · {겹침}" if note else 겹침 + # ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면 + # 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다). + reason = "" + if code is None: + reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다" + elif length <= 0: + reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다" + rows.append( + { + "work_item_code": code, + "name": str(entry.get("name") or type_id), + "spec": f"{entry.get('count')}개소", + "unit": "m", + "quantity": length, + "quantity_gross": raw if note else None, + "application_ratio_pct": None, + "application_ratio_breakdown": None, + "quantity_breakdown": None, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + # 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다. + "station_from": spans[0]["start_m"] if spans else None, + "station_to": spans[-1]["end_m"] if spans else None, + "excavation_method": None, + "spec_detail": f"{entry.get('count')}개소", + "composite_parts": None, + "structure_kind": None, + "blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING, + "blocked_reason": reason, + "variant_axis": None, + "variant_value": None, + "secondary_axes": None, + "spec_class": None, + "spec_class_basis": note, + "composite_not_ready": None, + "in_bill": bool(code and length > 0), + "in_bill_reason": "" if (code and length > 0) else reason, + "origin": ORIGIN_STRUCTURE, + } + ) + return rows + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows -- 2.54.0 From 385f5c303d1626712bd034de73473b4443b056e6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 14:18:06 +0900 Subject: [PATCH 439/441] =?UTF-8?q?fix(B08):=20=EC=94=A8=EC=95=97=EB=BF=9C?= =?UTF-8?q?=EC=96=B4=EB=B6=99=EC=9D=B4=EA=B8=B0=20=EB=B0=91=EC=88=98=20?= =?UTF-8?q?=E3=8E=A1=20=EC=B6=94=EA=B0=80=20+=20=EC=8B=9C=EA=B3=B5?= =?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EC=96=91=EC=9D=84=20=EB=B0=91=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=95=88=20=EB=B0=9B=EA=B2=8C=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 아홉 중 여덟째 — FP-05-24 는 원문 L2769·L2794 절 머리 아래 「(㎡당)」인데 「단위:」 글자 없이 **괄호만** 있어 마스터가 못 읽던 자리(원문 열어 확인). ⚠ 그리고 매핑 밑수 칸은 **사람이 손으로 적는 자리**라, 품셈에 섞여 있는 「(㎥/1대, 1일)」 같은 **시공량**을 적을 수 있음 — 그것은 밑수의 **역수**라 받으면 그 공종이 조용히 뒤집힌 단위를 가짐. `is_quantity_unit()` 으로 좁게 막음 (나눗셈·쉼표가 있거나 「일당·조·인」이면 안 받음). 애매하면 안 받는 쪽 — 안 받으면 대조가 없을 뿐이고, 잘못 받으면 틀린 단위로 검사를 통과시킴. 시험 741 통과 (B05 코리도 1건 기존 깨짐, 무관). Co-Authored-By: Claude Opus 5 (1M context) --- .../B08_Quantity_Engine_Handoff_Mapping.py | 22 ++++++++++++++++++- .../work_item_mapping_2026-01-01.json | 4 +++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py index 7bdc944e..19f2c66c 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff_Mapping.py @@ -148,6 +148,26 @@ def _latest_dataset_path(directory: Path | None = None) -> Path | None: return files[-1] if files else None +#: ⚠ **공종 수량 단위가 아닌 것** — 품셈 절 머리에 섞여 있는 모양들이다(2026-09-08 B09 전수). +#: `(㎥/1대, 1일)` 는 **시공량**이라 「1대가 하루에 몇 ㎥」이고, 밑수(「1㎥ 에 얼마」)의 +#: **역수**다. 그대로 밑수로 받으면 그 공종이 조용히 뒤집힌 단위를 갖는다. +#: `(일당)`·`(조)`·`(인)` 은 **품의 단위**이지 공종 단위가 아니다. +NON_QUANTITY_UNITS = frozenset({"일당", "조", "인", "일", "대", "인당"}) + + +def is_quantity_unit(unit: str) -> bool: + """그 글자가 **공종 수량의 밑수 단위**로 쓸 수 있는가. + + ⚠ **좁게 본다** — 애매하면 안 받는다. 안 받으면 대조가 없을 뿐이고, 잘못 받으면 + **틀린 단위로 검사를 통과시킨다**(그쪽이 더 나쁘다). + """ + text = str(unit or "").strip() + if not text or text in NON_QUANTITY_UNITS: + return False + # 「㎥/1대, 1일」처럼 나눗셈·쉼표가 있으면 시공량이거나 밑수가 둘이다. + return "/" not in text and "," not in text + + @dataclass class WorkItemMapping: """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" @@ -173,7 +193,7 @@ class WorkItemMapping: found: dict[str, str] = {} for row in (*self.earthwork, *self.haul, *self.structure): code, unit = row.get("work_item_code"), row.get("basis_unit") - if code and unit: + if code and unit and is_quantity_unit(str(unit)): found[str(code)] = str(unit) return found diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index bba043fc..61f3a127 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -101,7 +101,9 @@ { "group": "초류종자살포", "work_item_code": "FP-05-24", - "master_name": "씨앗뿜어붙이기" + "master_name": "씨앗뿜어붙이기", + "basis_unit": "㎡", + "basis_source": "산림사업 표준품셈(고시 2025-82) 원문 L2769·L2794 — 5-24-1·5-24-2 절 머리 바로 아래 「(㎡당)」. ⚠ 「단위:」 글자 없이 **괄호만** 적힌 모양이라 마스터가 못 읽은 자리(2026-09-08 원문 대조)." }, { "group": "되메우기", -- 2.54.0 From bbdae2d99ceff01c622f9cd57b664a95799dd264 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 16:10:34 +0900 Subject: [PATCH 440/441] =?UTF-8?q?knowledge(=EB=AF=B8=EA=B2=B0):=20?= =?UTF-8?q?=EC=98=86=EB=8F=84=EB=9E=91=20=EC=A0=80=ED=8F=AD=20=EB=AF=B8?= =?UTF-8?q?=EA=B2=B0=2022=20=EC=97=90=20=EC=8B=A4=EB=AC=BC=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=201=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 소광 기번8 내역서 「제형돌수로 B=0.75 m」 — 별표2 너비 0.5~1 m 범위 안. 한 공사지 한 줄이라 일반화 불가 표시 유지. Co-Authored-By: Claude Opus 5 (1M context) --- resources/knowledge/03_미결_및_확인사항.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/knowledge/03_미결_및_확인사항.md b/resources/knowledge/03_미결_및_확인사항.md index 16ad066a..592bc076 100644 --- a/resources/knowledge/03_미결_및_확인사항.md +++ b/resources/knowledge/03_미결_및_확인사항.md @@ -12,7 +12,7 @@ | 19 | [공통] ★STmate 실기 환경 확보 (STC 입출력 검증 전제) | STC = 발주처 제출 최종물, Aislo import/export 필수 요구사항(사용자 확정 2026-08-15). 포맷 명세는 [original/원가계산/STmate/](original/원가계산/STmate/) 정리 완료. **잔여 = STmate 실행 환경** — 생성·변형 STC를 열기→조회→수정→재계산→저장→재개방까지 검증해야 빈 테이블(BDQTY·SYSINFO) export 허용 여부·신규생성 규칙 확정 가능. 환경 없으면 [STC_왕복검증](original/원가계산/STmate/STC_왕복검증.md) R1~R9 실행 불가 | 선택지: ① STmate 보유·구매 ② 체험판 ③ 설계사무소 협조. **개발 단계 사용자 결정** | [STmate 포맷 명세](original/원가계산/STmate/_meta.md), [STC_난독화_상태](original/원가계산/STmate/STC_난독화_상태.md) | 미결 — 사용자 결정 대기 (2026-08-15) | | 20 | [임도] 유토곡선 평형선·극값 임계·balloon 표기 기준 | 법령·행정규칙·표준시방서·교본에 **세부 규칙 없음**(2026-09-03 확인). KDS 44 30 00 2.3.3은 「구간별 균형 배분·운반거리 최소화」까지만 규정. 실무 도면(`유토곡선.dwg`)은 압축 DWG라 표기 문자열 추출 불가 — 표기 관행은 도면 육안 확인 필요. 실무 곡선 원본 6건 관측치는 문서에 정리 | 실무 유토곡선 도면 육안 확인 후 사용자 협의로 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §2·§3 | 미결 — 근거 공백 (2026-09-03) | | 21 | [임도] 토공 운반장비 한계거리 — 도자 60m vs 70m | 실무 `EARTH.DAT` 헤더 6건 전부 `무대 20.0 / 도자 60.0`. Aislo 현행 `EARTHWORK_HAUL_EQUIPMENT_LIMITS_M`은 20/70. 현행 법령·품셈에 장비별 한계거리 규정 없음 — 두 값 모두 후보 | 개발 단계 사용자 협의로 채택값 확정 | [유토곡선_토량배분](technical_info/01_임도/03_계산정보/유토곡선_토량배분.md) §4 | 미결 — 사용자 결정 대기 (2026-09-03) | -| 22 | [임도] 옆도랑(측구) 사다리꼴 단면 — 저폭 근거 공백 | 별표2 근거는 **너비 0.5~1 m · 깊이 30 ㎝ 내외**까지뿐([측구 §1](technical_info/01_임도/02_상세설계/측구.md))이고 **저폭 수치는 원문 전수에 없음**. Aislo 현행 0.9/0.3/0.3(상단폭·저폭·깊이)은 표준횡단도면 판독값이며 측벽이 45°라 [통수단면 §4](technical_info/01_임도/03_계산정보/통수단면.md) 경제 단면(사다리형 측벽 **60°**, B≒1.155H)과 어긋남 — 두 축 중 무엇을 기본으로 둘지 미정 | 개발 단계 사용자 협의로 채택 축 확정 (도면 판독값 vs 경제 단면식) | [측구](technical_info/01_임도/02_상세설계/측구.md) §1·[통수단면](technical_info/01_임도/03_계산정보/통수단면.md) §4 | 미결 — 사용자 결정 대기 (2026-09-08) | +| 22 | [임도] 옆도랑(측구) 사다리꼴 단면 — 저폭 근거 공백 | 별표2 근거는 **너비 0.5~1 m · 깊이 30 ㎝ 내외**까지뿐([측구 §1](technical_info/01_임도/02_상세설계/측구.md))이고 **저폭 수치는 원문 전수에 없음**. Aislo 현행 0.9/0.3/0.3(상단폭·저폭·깊이)은 표준횡단도면 판독값이며 측벽이 45°라 [통수단면 §4](technical_info/01_임도/03_계산정보/통수단면.md) 경제 단면(사다리형 측벽 **60°**, B≒1.155H)과 어긋남 — 두 축 중 무엇을 기본으로 둘지 미정 **실물 근거 1건(2026-09-08)**: 소광 기번8 내역서 「제형돌수로 **B=0.75 m**」 — 별표2 너비 범위 안. ⚠ 한 공사지 한 줄이라 일반화 불가 | 개발 단계 사용자 협의로 채택 축 확정 (도면 판독값 vs 경제 단면식) | [측구](technical_info/01_임도/02_상세설계/측구.md) §1·[통수단면](technical_info/01_임도/03_계산정보/통수단면.md) §4 | 미결 — 사용자 결정 대기 (2026-09-08) | ## §2 확인사항 — 임도교본(2019) vs 사방교본(2023) 충돌 리스트 (2026-08-12, 최종검증에서 도출) -- 2.54.0 From 635eb69003ae386c687c8e3a19bea5dc82be1e37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 17:01:16 +0900 Subject: [PATCH 441/441] =?UTF-8?q?chore(db):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=ED=86=B5=EC=A7=B8=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=EB=A5=BC=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=EC=97=90=20=EB=91=A0=20(=EC=B0=BD=EB=B3=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠ 네 창이 **같은 원본에서 같은 방법으로** 떠야 수치를 견줄 수 있는데, 쓰던 스크립트가 `tmp/` 에 있어 다른 창이 못 봤음(2026-09-08 랩탑 두 창이 막힘). 원본은 SELECT 만 함 — 원본 행·파일에 쓰지 않음. 뜨는 것 — DB 열 표(projects·input_files·processed_point_cloud·surface_models· routes·route_points·route_statistics·longitudinal_sections·cross_sections· project_workflow_stages)와 저장소 폴더 통째. 자동증가 id 는 새로 받고 참조를 다시 이음. 파일 안에 박힌 옛 UUID 와 초기값 스냅숏의 surface_model_id 도 치환함. ⚠ DB 만 뜨거나 파일만 떠서는 화면이 안 섬 — 수량은 DB(측점·횡단)를 보고 구조물·관 정본은 파일(structures.json·pipe_points.json)이라 둘 다 필요함. 쓰는 법: ./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>" Co-Authored-By: Claude Opus 5 (1M context) --- db_management/tools_clone_project.py | 267 +++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 db_management/tools_clone_project.py diff --git a/db_management/tools_clone_project.py b/db_management/tools_clone_project.py new file mode 100644 index 00000000..520a22d2 --- /dev/null +++ b/db_management/tools_clone_project.py @@ -0,0 +1,267 @@ +"""프로젝트 통째 복제 — **검증용 프로젝트를 창마다 하나씩 뜨는 도구** (2026-09-08). + +⚠ **왜 저장소에 두나** — 네 창이 **같은 원본에서 같은 방법으로** 떠야 수치를 견줄 수 있다. + `tmp/` 는 창마다 따로라 서로 못 봄(2026-09-08 랩탑 두 창이 막힌 자리). + +⚠ **원본은 읽기만 한다** — SELECT 뿐이고 원본 행·파일에 쓰지 않는다. + ⚠ 다만 **복제 중에 원본이 바뀌면 창마다 복제본이 달라진다** — 원본을 얼린 뒤 뜰 것. + +무엇을 뜨나 + DB projects · input_files · processed_point_cloud · surface_models · routes · + route_points · route_statistics · longitudinal_sections · cross_sections · + project_workflow_stages + ⚠ 자동증가 id 는 **새로 받고** 참조(외래키)를 새 id 로 다시 이어 붙인다. + 파일 저장소 폴더 통째(robocopy). 경로가 프로젝트 루트 기준 상대라 그대로 쓰인다. + 치환 파일 안에 박힌 **옛 프로젝트 UUID**, 초기값 스냅숏의 **surface_model_id**. + +⚠ **DB 만 뜨거나 파일만 떠서는 안 된다** — 수량(B08)은 DB(측점·횡단)를 보고, + 구조물·관 정본은 파일(`structures.json`·`pipe_points.json`)이라 **둘 다** 있어야 화면이 선다. + +쓰는 법 + ./venv/Scripts/python.exe db_management/tools_clone_project.py <원본 UUID> "<새 이름>" +""" + +import argparse +import asyncio +import json +import subprocess +import sys +import time +from pathlib import Path +from uuid import uuid4 + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +import aiomysql + +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_USER + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="프로젝트를 통째로 복제한다(원본은 읽기만).") + parser.add_argument("source_id", help="원본 프로젝트 UUID") + parser.add_argument("new_name", help="새 프로젝트 이름") + return parser.parse_args() + + +ARGS = _args() +SOURCE_ID = ARGS.source_id +NEW_NAME = ARGS.new_name + + +async def _rows(cursor, sql, args): + await cursor.execute(sql, args) + return await cursor.fetchall() + + +async def _insert(cursor, table, row: dict) -> int: + names = ", ".join(f"`{key}`" for key in row) + holders = ", ".join(["%s"] * len(row)) + await cursor.execute(f"INSERT INTO `{table}` ({names}) VALUES ({holders})", tuple(row.values())) + return int(cursor.lastrowid) + + +async def main() -> None: + connection = await aiomysql.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + db=DB_NAME, + charset="utf8mb4", + ) + try: + async with connection.cursor(aiomysql.DictCursor) as cursor: + source = (await _rows(cursor, "SELECT * FROM projects WHERE id = %s", (SOURCE_ID,)))[0] + input_files = await _rows( + cursor, "SELECT * FROM input_files WHERE project_id = %s ORDER BY id", (SOURCE_ID,) + ) + clouds = await _rows( + cursor, + "SELECT * FROM processed_point_cloud WHERE project_id = %s ORDER BY id", + (SOURCE_ID,), + ) + models = await _rows( + cursor, + "SELECT * FROM surface_models WHERE project_id = %s ORDER BY id", + (SOURCE_ID,), + ) + routes = await _rows( + cursor, "SELECT * FROM routes WHERE project_id = %s ORDER BY id", (SOURCE_ID,) + ) + stages = await _rows( + cursor, + "SELECT * FROM project_workflow_stages WHERE project_id = %s ORDER BY stage_no", + (SOURCE_ID,), + ) + route_ids = [route["id"] for route in routes] + points: list = [] + statistics: list = [] + longitudinal: list = [] + crosses: list = [] + if route_ids: + holders = ", ".join(["%s"] * len(route_ids)) + points = await _rows( + cursor, + f"SELECT * FROM route_points WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + statistics = await _rows( + cursor, + f"SELECT * FROM route_statistics WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + longitudinal = await _rows( + cursor, + "SELECT * FROM longitudinal_sections" + f" WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + crosses = await _rows( + cursor, + f"SELECT * FROM cross_sections WHERE route_id IN ({holders}) ORDER BY id", + route_ids, + ) + + new_id = str(uuid4()) + source_root = Path(resolve_stored_project_path(source["storage_path"])) + new_storage = "/".join(source["storage_path"].split("/")[:-1] + [new_id]) + new_root = source_root.parent / new_id + + # 1) 파일 먼저 - 실패하면 DB 를 건드리지 않은 채로 끝난다. + print(f"파일 복사 시작: {source_root.name} -> {new_id}") + started = time.perf_counter() + result = subprocess.run( + [ + "robocopy", + str(source_root), + str(new_root), + "/E", + "/NFL", + "/NDL", + "/NJH", + "/NJS", + "/MT:8", + ], + capture_output=True, + text=True, + ) + if result.returncode >= 8: + raise RuntimeError(f"robocopy 실패 (코드 {result.returncode})") + print(f"파일 복사 완료: {time.perf_counter() - started:.0f}초") + + # 2) DB - 한 트랜잭션으로. + await connection.begin() + async with connection.cursor(aiomysql.DictCursor) as cursor: + project = dict(source) + project["id"] = new_id + project["name"] = NEW_NAME + project["storage_path"] = new_storage + await _insert(cursor, "projects", project) + + file_map: dict[int, int] = {} + for row in input_files: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + file_map[old] = await _insert(cursor, "input_files", row) + + cloud_map: dict[int, int] = {} + for row in clouds: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["input_file_id"] = file_map.get(row["input_file_id"], row["input_file_id"]) + cloud_map[old] = await _insert(cursor, "processed_point_cloud", row) + + model_map: dict[int, int] = {} + for row in models: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["source_file_id"] = file_map.get(row["source_file_id"], row["source_file_id"]) + row["processed_cloud_id"] = cloud_map.get( + row["processed_cloud_id"], row["processed_cloud_id"] + ) + model_map[old] = await _insert(cursor, "surface_models", row) + + route_map: dict[int, int] = {} + for row in routes: + row = dict(row) + old = row.pop("id") + row["project_id"] = new_id + row["surface_model_id"] = model_map.get( + row["surface_model_id"], row["surface_model_id"] + ) + route_map[old] = await _insert(cursor, "routes", row) + + for table, rows in ( + ("route_points", points), + ("route_statistics", statistics), + ("longitudinal_sections", longitudinal), + ("cross_sections", crosses), + ): + for row in rows: + row = dict(row) + row.pop("id") + row["route_id"] = route_map.get(row["route_id"], row["route_id"]) + if "project_id" in row: + row["project_id"] = new_id + await _insert(cursor, table, row) + + for row in stages: + row = dict(row) + row.pop("id") + row["project_id"] = new_id + params = row.get("params") + if params: + # 단계 설정에 박힌 옛 id 를 새 id 로 바꾼다(입력 파일·지표면 모델). + data = json.loads(params) + if data.get("input_file_id") is not None: + old_file = int(data["input_file_id"]) + data["input_file_id"] = str(file_map.get(old_file, old_file)) + if data.get("surface_model_id") is not None: + old_model = int(data["surface_model_id"]) + data["surface_model_id"] = model_map.get(old_model, old_model) + row["params"] = json.dumps(data, ensure_ascii=False) + await _insert(cursor, "project_workflow_stages", row) + await connection.commit() + print( + f"DB 복제 완료: 입력파일 {len(file_map)} · 지표면 {len(model_map)}" + f" · 노선 {len(route_map)}" + f" · 정점 {len(points)} · 횡단 {len(crosses)} · 단계 {len(stages)}" + ) + + # 3) 파일 안에 박힌 옛 프로젝트 id 치환 + 초기값 스냅샷의 지표면 모델 id 교정. + replaced = [] + for path in new_root.rglob("*.json"): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + if SOURCE_ID not in text: + continue + path.write_text(text.replace(SOURCE_ID, new_id), encoding="utf-8") + replaced.append(str(path.relative_to(new_root))) + print("프로젝트 id 치환:", replaced) + + snapshot = new_root / "initial_snapshot" / "db.json" + if snapshot.is_file(): + dump = json.loads(snapshot.read_text(encoding="utf-8")) + changed = False + for route in dump.get("routes", []): + old_model = route.get("surface_model_id") + if old_model in model_map: + route["surface_model_id"] = model_map[old_model] + changed = True + if changed: + snapshot.write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8") + print("초기값 스냅샷 지표면 id 교정:", changed) + + print(f"\n새 프로젝트: {NEW_NAME}\n id = {new_id}\n 저장소 = {new_storage}") + finally: + connection.close() + + +asyncio.run(main()) -- 2.54.0