From 1f3b30e698b9a54be591e9720f54b0ab12908eb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 14:20:54 +0900 Subject: [PATCH] =?UTF-8?q?@=20feat(B06):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=C2=B7=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B3=84=EC=82=B0=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EB=A7=88=EB=A0=A8=20+=20=EC=83=81=EB=8B=A8=EC=B8=A1=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계산 자리 일원화(CLAUDE.md 5장) — 브라우저에서만 돌던 두 계산을 서버가 같은 TS 로 한 번 더 돌려 정본에 얹음. 파이썬 포팅 금지(기하가 두 벌이 되면 그림과 수량이 갈림). - B06_Section_Server_Calc_Node.ts 신설 — 구조물 폐회로 면적 계산 후 그 위에서 유토곡선을 쌓음(화면과 같은 순서). balloon 위치는 서버가 만들지 않음. - B06_Section_Structure_Layouts.ts 신설 — 정본만 읽는 제어기 흉내를 B07 도면에서 떼어 공용화. B07·서버가 같은 한 벌을 씀. - common_util_node_bundle.py 신설 — 번들 빌드·실행 배관 공용화(코리도도 이걸 씀). - 전처리 체인(초기값 스냅샷 앞)·[저장]·[확정]에서 서버 재계산 호출. - 상단측(측구 방향) 변경이 B05 [임시저장]에만 실리던 것을 B06 [저장]·[확정]에도 실음 — flushUphillOverrides. - 죽은 세션 등록 항목 pipes 제거(읽는 곳도 쓰는 곳도 없었음). 검증: tsc --noEmit 통과, pytest 386 passed, tmp/tests/test_b06_server_calc_node.mjs 통과. Co-Authored-By: Claude Opus 5 (1M context) @ --- .gitignore | 4 +- A00_Common/b_page_state.ts | 5 +- B03_FileInput/B03_FileInput_Service_Chain.py | 11 ++ B05_Profile/B05_Profile_Api_Fetch.ts | 13 ++ B05_Profile/B05_Profile_Corridor_Prebuild.py | 71 +--------- B06_Section/B06_Section_Router_Confirm.py | 22 +++ B06_Section/B06_Section_Server_Calc_Node.ts | 100 ++++++++++++++ .../B06_Section_Server_Calc_Prebuild.py | 125 ++++++++++++++++++ B06_Section/B06_Section_Structure_Layouts.ts | 107 +++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 4 +- B06_Section/B06_Section_UI_Page_Persist.ts | 6 + .../B07_DesignDetail_UI_Cad_Structures.ts | 103 +-------------- .../common_util_cross_structure_areas.ts | 6 +- common_util/common_util_node_bundle.py | 103 +++++++++++++++ package.json | 3 +- 15 files changed, 509 insertions(+), 174 deletions(-) create mode 100644 B06_Section/B06_Section_Server_Calc_Node.ts create mode 100644 B06_Section/B06_Section_Server_Calc_Prebuild.py create mode 100644 B06_Section/B06_Section_Structure_Layouts.ts create mode 100644 common_util/common_util_node_bundle.py diff --git a/.gitignore b/.gitignore index e0e2e695..aa588524 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,6 @@ tmp/ **/.obsidian/ # 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04). # 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py). -config/corridor_node/ \ No newline at end of file +config/corridor_node/ +# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06). +config/server_calc_node/ \ No newline at end of file diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2a48d6d7..683e89ab 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -92,8 +92,9 @@ export const STATE_REGISTRY = { structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` }, - /** 관 위치(되돌리기 스냅샷이 함께 본다). */ - pipes: { bucket: "draft", scope: "project", legacy: () => "b05:pipes" }, + /* `pipes`(옛 `b05:pipes`)는 2026-09-06 에 뺐다 — 읽는 곳도 쓰는 곳도 없었다. + 관 위치의 정본은 `pipe_points.json` 이고, 화면은 [저장] 때 `savePipes()` 로 바로 + 내보낸다. 초안처럼 보이는 이름만 남아 대응표에서 「저장 자리 없음」으로 잡혔다. */ /** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */ "structure-pick": { bucket: "draft", diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index b826fda4..00e959af 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -280,6 +280,17 @@ async def run_auto_design_chain( except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다 logger.exception("코리도 사전 생성 실패: project_id=%s", project_id) + # 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에 + # 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는 + # 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴 + # 기준선에도 보정이 담긴다. + try: + from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + + await recompute_server_side(project_id, route_id) + except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다 + logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id) + # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index ac8448d1..6666c1d6 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -266,6 +266,19 @@ export async function confirmRoute( }); } +/** 세션에 쌓인 상단측(측구 방향) 변경분을 정본으로 내보낸다 — B06 [저장]·[확정]용. + * 3D 램프 클릭은 B05 화면에서만 생기지만 저장 버튼은 B06 에도 있다(B05·B06 은 한 페이지). + * B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다 + * (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */ +export async function flushUphillOverrides(projectId: string): Promise { + const stored = readState>("uphill", projectId); + const overrides = Object.entries(stored ?? {}) + .filter(([, side]) => side === "left" || side === "right") + .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); + if (!overrides.length) return; + await confirmRoute(projectId, { uphill_overrides: overrides }, false); +} + /** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */ export interface RouteResetResponse { status: string; diff --git a/B05_Profile/B05_Profile_Corridor_Prebuild.py b/B05_Profile/B05_Profile_Corridor_Prebuild.py index a69be261..9361fac2 100644 --- a/B05_Profile/B05_Profile_Corridor_Prebuild.py +++ b/B05_Profile/B05_Profile_Corridor_Prebuild.py @@ -16,8 +16,6 @@ from __future__ import annotations import asyncio import json import logging -import os -import subprocess import tempfile from pathlib import Path from typing import Any @@ -25,76 +23,13 @@ from uuid import UUID from B05_Profile.B05_Profile_Repository import get_route_points from B05_Profile.B05_Profile_Router_Corridor import corridor_path +from common_util.common_util_node_bundle import build_bundle, bundle_stale, run_node from config.config_db import get_db_pool logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js" -# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더. -_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") -# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다. -_BUILD_TIMEOUT_S = 300 -_RUN_TIMEOUT_S = 600 - - -def _node_env() -> dict[str, str]: - """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" - env = os.environ.copy() - node_modules = ROOT / "config" / "node_modules" - env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" - env["NODE_PATH"] = str(node_modules) - return env - - -def _bundle_stale() -> bool: - """번들이 없거나 TS 원본보다 오래됐으면 참. - - 번들이 낡으면 서버와 화면이 **다른 기하**를 만든다 — 이 판정이 그것을 막는 유일한 - 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. - """ - if not BUNDLE.is_file(): - return True - built_at = BUNDLE.stat().st_mtime - for directory in _SOURCE_DIRS: - for path in (ROOT / directory).rglob("*.ts"): - if path.stat().st_mtime > built_at: - return True - return False - - -def _build_bundle() -> bool: - result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 - "npm run build:corridor", - shell=True, - cwd=str(ROOT), - env=_node_env(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_BUILD_TIMEOUT_S, - ) - if result.returncode != 0: - logger.error("코리도 번들 빌드 실패:\n%s", result.stderr) - return False - return True - - -def _run_node(input_path: Path, output_path: Path) -> int: - result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 - ["node", str(BUNDLE), str(input_path), str(output_path)], - cwd=str(ROOT), - env=_node_env(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_RUN_TIMEOUT_S, - ) - if result.returncode != 0: - logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr) - return result.returncode async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None: @@ -121,7 +56,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id) return False - if _bundle_stale() and not await asyncio.to_thread(_build_bundle): + if bundle_stale(BUNDLE) and not await asyncio.to_thread(build_bundle, "build:corridor"): return False target = corridor_path(Path(project_root), route_id) @@ -133,7 +68,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: json.dumps({"detail": detail, "route_points": points}, default=float), "utf-8", ) - if await asyncio.to_thread(_run_node, source, built) != 0: + if await asyncio.to_thread(run_node, BUNDLE, source, built) != 0: return False await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(_replace, built, target) diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 418f1039..0fd21357 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -147,6 +147,25 @@ async def _apply_section_edits( @router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) +async def _recompute_server_side(project_id: UUID, route_id: int) -> None: + """구조물 면적·유토곡선을 서버가 다시 계산해 정본에 얹는다(2026-09-06). + + 브라우저가 보낸 값을 그대로 받아 적지 않는다 — 정본은 서버가 만든다 + (CLAUDE.md 5장 「계산 자리」). 사용자 조작(patch)이 먼저 들어간 **뒤**에 돌아야 + 바뀐 벽 위치가 면적·유토곡선에 실린다. 실패는 비치명적이다. + """ + try: + from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + + await recompute_server_side(project_id, route_id) + except Exception: + logger.exception( + "횡단 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s", + project_id, + route_id, + ) + + async def save_sections( project_id: UUID, route_id: int, @@ -200,6 +219,7 @@ async def save_sections( except Exception: await connection.rollback() raise + await _recompute_server_side(project_id, route_id) return SectionConfirmResponse( project_id=str(project_id), route_id=route_id, confirmed=False ) @@ -284,6 +304,8 @@ async def confirm_sections( await connection.rollback() raise + await _recompute_server_side(project_id, route_id) + # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). # 파일 기반·비치명적: 실패해도 확정은 유지한다. try: diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts new file mode 100644 index 00000000..6503b045 --- /dev/null +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B06_Section_Server_Calc_Node.ts + * 브라우저에서만 돌던 횡단 계산을 **서버가 한 번 돌리는** 진입점 — 구조물 폐회로 + * 절·성토 면적 + 그것을 쌓아 만든 유토곡선. + * + * 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 이 두 값은 지금까지 브라우저에서만 + * 나왔다. 사용자가 B06 을 한 번도 안 열면 값이 없고, 저장·확정 뒤 서버가 다시 계산하면 + * 구조물을 모르는 표준값으로 되돌아갔다. 코리도(`B05_Profile_Corridor_Node.ts`)와 같은 + * 방식으로 **브라우저가 쓰는 코드를 서버가 그대로 실행**한다 — 계산을 두 벌로 짜지 않는다. + * + * 순서가 중요하다: 면적 보정을 **먼저** 얹고 그 위에서 유토곡선을 쌓는다. 화면도 같은 + * 순서다(카드를 그리며 면적을 고친 뒤 유토곡선을 낸다). + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion, + * natural_spoil_min_ground_slope, haul_equipment_limits } } + * 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null } + * — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다. + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; +import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; +import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { computeStoredLayouts, trimOfLayouts } from "./B06_Section_Structure_Layouts"; + +interface ServerCalcInput { + detail: SectionDetailResponse; + context?: { + earthwork_conversion?: Parameters[1]; + natural_spoil_min_ground_slope?: number | null; + haul_equipment_limits?: Parameters[1]; + }; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput; +const sections: CrossSection[] = input.detail?.cross_sections ?? []; + +/** 화면(`B06_Section_UI_Cross_View.applyStructureAreas`)과 같은 입력을 만든다. */ +function areasOf(section: CrossSection): Record | null { + const layouts = computeStoredLayouts(section, sections); + if (!layouts) return null; + const trim = trimOfLayouts(layouts); + const design = layouts.design; + if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null; + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return null; + const round = (value: number): number => Number(value.toFixed(4)); + const out: Record = { + chainage_m: section.chainage_m, + cut_area_m2: round(areas.cutAreaM2), + fill_area_m2: round(areas.fillAreaM2), + }; + // 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다. + if (typeof design.cut_soil_area_m2 === "number") { + out.cut_soil_area_m2 = round(areas.cutSoilAreaM2); + out.cut_rock_area_m2 = round(areas.cutRockAreaM2); + } + return out; +} + +const areas = sections.map(areasOf).filter((entry): entry is Record => !!entry); +// 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다. +areas.forEach((row) => { + const section = sections.find((item) => item.chainage_m === row.chainage_m); + const design = section?.design; + if (!design) return; + for (const key of ["cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"]) { + if (typeof row[key] === "number") (design as Record)[key] = row[key]; + } +}); + +// 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존). +const conversion = input.context?.earthwork_conversion; +const result = conversion + ? computeMassHaul(sections, conversion, input.context?.natural_spoil_min_ground_slope ?? undefined) + : null; +const massHaul = result + ? massHaulPayload(result, computeHaulPlan(result, input.context?.haul_equipment_limits)) + : null; + +writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul })); diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py new file mode 100644 index 00000000..158aa0a2 --- /dev/null +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -0,0 +1,125 @@ +"""브라우저에서만 돌던 횡단 계산을 **서버가** 돌려 정본에 남긴다(2026-09-06). + +대상 둘 — + ① 구조물이 선 측점의 절·성토 면적: 기슭막이·세월교·BOX암거가 서면 성토 사면이 벽에서 + 끊겨 지반선과 설계선이 이루는 폐회로가 달라진다. + ② 그 면적을 쌓아 만드는 유토곡선. + +왜 — 둘 다 화면에서만 돌아, 사용자가 B06 을 한 번도 안 열면 값이 없고 저장·확정 뒤에는 +구조물을 모르는 표준값으로 되돌아갔다(CLAUDE.md 5장 「계산 자리」 — 금지 항목이던 자리). + +**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`) +으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데 +수량은 저렇다」가 생긴다. + +실패는 비치명적이다 — 보정 전(표준) 값이 그대로 남고 화면은 예전처럼 스스로 고친다. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from B06_Section.B06_Section_Repository import ( + get_longitudinal_section, + merge_cross_section_design_patch, + merge_longitudinal_section_data, +) +from common_util.common_util_node_bundle import run_bundle_json +from config.config_db import get_db_pool +from config.config_system import ( + EARTHWORK_CONVERSION_FACTORS, + EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, + NATURAL_SPOIL_MIN_GROUND_SLOPE, +) + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" +_NPM_SCRIPT = "build:server-calc" +# 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다. +_AREA_KEYS = ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2") + + +def _mass_haul_context() -> dict[str, Any]: + """유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.""" + return { + "earthwork_conversion": EARTHWORK_CONVERSION_FACTORS, + "natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE, + "haul_equipment_limits": [ + {"key": key, "max_distance_m": limit} + for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M + ], + } + + +async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: + """구조물 면적·유토곡선을 다시 계산해 정본에 얹는다. 고친 측점 수를 돌려준다.""" + from B06_Section.B06_Section_Router import get_section_detail + + project_uuid = UUID(str(project_id)) + response = await get_section_detail(project_uuid, route_id) + payload = getattr(response, "model_dump", None) + if payload is None: # JSONResponse = 실패 + logger.warning("서버 재계산: 종횡단 상세를 못 받음 (route_id=%s)", route_id) + return 0 + + output = await asyncio.to_thread( + run_bundle_json, + BUNDLE, + _NPM_SCRIPT, + {"detail": payload(mode="json"), "context": _mass_haul_context()}, + ) + if not isinstance(output, dict): + return 0 + rows = output.get("areas") + mass_haul = output.get("mass_haul") + + updated = 0 + pool = get_db_pool() + async with pool.acquire() as connection: + # balloon 위치는 **사용자가 끌어 옮긴 화면값**이다 — 서버가 만들지 않으므로 + # 저장분에서 떼어 새 유토곡선에 도로 붙인다(2026-09-06). + if isinstance(mass_haul, dict): + existing = await get_longitudinal_section(connection, project_uuid, route_id) + stored = (existing or {}).get("data") or {} + offsets = (stored.get("mass_haul") or {}).get("balloon_offsets") + if offsets is not None: + mass_haul["balloon_offsets"] = offsets + + await connection.begin() + try: + for row in rows if isinstance(rows, list) else []: + patch: dict[str, Any] = { + key: float(row[key]) + for key in _AREA_KEYS + if isinstance(row.get(key), (int, float)) + } + if not patch: + continue + if await merge_cross_section_design_patch( + connection, + route_id=route_id, + chainage_m=float(row["chainage_m"]), + patch=patch, + ): + updated += 1 + if isinstance(mass_haul, dict): + await merge_longitudinal_section_data( + connection, route_id=route_id, data_patch={"mass_haul": mass_haul} + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + logger.info( + "서버 재계산: route_id=%s 구조물 측점 %s곳 갱신, 유토곡선 %s", + route_id, + updated, + "갱신" if isinstance(mass_haul, dict) else "없음", + ) + return updated diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts new file mode 100644 index 00000000..8c45d119 --- /dev/null +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B06_Section_Structure_Layouts.ts + * 정본(`section.design`)만 읽어 **구조물 기하 한 벌**을 내는 자리 — 화면·조작 없이 돈다. + * + * 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 같은 기하를 세 곳이 쓴다: + * ① B06 횡단 카드(사용자 조작 중) ② B07 도면 작도 ③ 서버 초기 계산(Node 진입점). + * ①은 조작 제어기를 물고 돌아야 하고, ②·③은 저장된 값만 보면 된다. ②가 갖고 있던 + * 「제어기 흉내내기」를 여기로 옮겨 ③이 그대로 쓴다 — 기하를 두 벌로 만들지 않는다. + * + * DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire"; +import type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom"; +import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; + +/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +function storedWallAdjust(section: CrossSection, role: string): WallAdjust { + const stored = section.design?.revet_adjust?.[role]; + return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; +} + +/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */ +const revetOffset: RevetOffsetControl = { + adjustFor: (section, role) => storedWallAdjust(section, role), + storedAdjustFor: (section, role) => + section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, + selectedFor: () => null, + highlightFor: () => null, + select: () => undefined, + syncApplied: () => undefined, + update: () => undefined, + reset: () => undefined, +}; + +const extraWalls: ExtraWallControl = { + countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, + setCount: () => undefined, + equalize: () => undefined, + consumeEqualize: () => false, + syncCount: () => undefined, +}; + +const inletStructure: InletStructureControl = { + valueFor: (section) => section.design?.inlet_structure ?? "auto", + adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), + set: () => undefined, + updateAdjust: () => undefined, + resetAdjust: () => undefined, +}; + +/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ +export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) { + const design = section.design; + if (!design) return null; + const designZAt = (chainageM: number): number | null => { + const found = sections.find( + (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, + ); + return found?.design?.design_elevation_m ?? null; + }; + const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); + const culvert = computeCardCulvert( + section, + section.samples, + null, + revetOffset, + inletStructure, + extraWalls, + link, + ); + const box = computeBoxLayout(section, section.samples, { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) }, + }); + const ford = computeFordLayout(section, section.samples, { + inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, + outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, + }); + // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. + const own = + !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; + return { design, link, culvert, box, ford, own }; +} + +export type StoredLayouts = NonNullable>; + +/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */ +export function trimOfLayouts(layouts: StoredLayouts) { + return ( + layouts.culvert?.designTrim ?? + layouts.ford?.designTrim ?? + layouts.box?.designTrim ?? + layouts.own?.designTrim + ); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 0931fa6f..7000aeac 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -705,8 +705,8 @@ export function createCrossSectionCard( * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. * - * ⚠ 서버는 아직 구조물 기하를 모른다 — 저장·확정 뒤 서버가 다시 계산하면 표준 값으로 - * 돌아간다(계획서 3-2 남은 몫). + * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 + * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). */ function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void { const design = section.design; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1117d351..785c2a84 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -17,6 +17,7 @@ import { type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; 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 type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; @@ -194,6 +195,11 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await ctx.flushCulvertOptions(); + // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 + // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 + // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 + // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. + await flushUphillOverrides(projectId).catch(() => undefined); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index fa0cef2d..6fcaff42 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -14,40 +14,19 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { + computeStoredLayouts as computeLayouts, + type StoredLayouts, +} from "../B06_Section/B06_Section_Structure_Layouts"; import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; -import { - computeBoxLayout, - DEFAULT_BOX_SIDE_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Box_Geom"; import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { - DEFAULT_BASIN_ADJUST, - ZERO_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import { - computeCardCulvert, - culvertLinkFor, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; -import type { - ExtraWallControl, - InletStructureControl, - RevetOffsetControl, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; -import { - computeFordLayout, - DEFAULT_FORD_WALL_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import { appendCrossDesignOverlay, appendPavementOverlay, } from "../B06_Section/B06_Section_UI_Cross_Design"; -import { - appendRevetmentOverlay, - computeRevetmentLayout, -} from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment"; /** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ export interface CrossPlacement { @@ -440,77 +419,7 @@ function entityBox(entity: Record): number[] | null { return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null; } -// --------------------------------------------------------------------------- -// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다. -// --------------------------------------------------------------------------- -function storedWallAdjust(section: CrossSection, role: string): WallAdjust { - const stored = section.design?.revet_adjust?.[role]; - return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; -} - -const revetOffset: RevetOffsetControl = { - adjustFor: (section, role) => storedWallAdjust(section, role), - storedAdjustFor: (section, role) => - section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, - selectedFor: () => null, - highlightFor: () => null, - select: () => undefined, - syncApplied: () => undefined, - update: () => undefined, - reset: () => undefined, -}; - -const extraWalls: ExtraWallControl = { - countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, - setCount: () => undefined, - equalize: () => undefined, - consumeEqualize: () => false, - syncCount: () => undefined, -}; - -const inletStructure: InletStructureControl = { - valueFor: (section) => section.design?.inlet_structure ?? "auto", - adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), - set: () => undefined, - updateAdjust: () => undefined, - resetAdjust: () => undefined, -}; - -/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ -function computeLayouts(section: CrossSection, sections: CrossSection[]) { - const design = section.design; - if (!design) return null; - const designZAt = (chainageM: number): number | null => { - const found = sections.find( - (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, - ); - return found?.design?.design_elevation_m ?? null; - }; - const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); - const culvert = computeCardCulvert( - section, - section.samples, - null, - revetOffset, - inletStructure, - extraWalls, - link, - ); - const box = computeBoxLayout(section, section.samples, { - left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) }, - right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) }, - }); - const ford = computeFordLayout(section, section.samples, { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, - }); - // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. - const own = - !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; - return { design, link, culvert, box, ford, own }; -} - -type Layouts = NonNullable>; +type Layouts = StoredLayouts; /** * 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은 diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts index 8760c6c1..f3ec9fc5 100644 --- a/common_util/common_util_cross_structure_areas.ts +++ b/common_util/common_util_cross_structure_areas.ts @@ -11,9 +11,9 @@ * 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이 * 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. * - * ⚠ 파이썬 짝이 아직 없다 — 서버는 구조물 기하를 모른다(계획서 3-2). 그래서 이 보정은 - * **브라우저 계산에만** 실린다. 저장·확정 뒤 서버가 다시 계산하면 표준 설계선 값으로 - * 돌아간다. 서버 쪽 반영은 별도 작업이다. + * 파이썬 짝은 만들지 않는다 — 서버도 **이 파일을 그대로 실행**한다 + * (`B06_Section_Structure_Areas_Node.ts` → 번들, 2026-09-06). 전처리 체인 끝과 + * [저장]·[확정] 때 서버가 돌려 정본에 얹으므로, 브라우저를 한 번도 안 열어도 값이 선다. * ========================================================================== */ import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py new file mode 100644 index 00000000..4e85515c --- /dev/null +++ b/common_util/common_util_node_bundle.py @@ -0,0 +1,103 @@ +"""브라우저용 TS 를 **서버에서 그대로 실행**하기 위한 공통 배관(2026-09-06 분리). + +CLAUDE.md 5장 「계산 자리」 — 같은 계산을 파이썬으로 다시 짜지 않고, 화면이 쓰는 TS 를 +Node 진입점으로 감싸 서버가 부른다. 코리도(`B05_Profile_Corridor_Prebuild`)가 첫 사례고 +구조물 면적(`B06_Section_Structure_Areas_Prebuild`)이 뒤따르면서, 번들 빌드·실행 배관이 +두 벌이 되어 여기로 모았다. **계산은 이 파일에 없다** — 실행 껍데기만 있다. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더. +SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") +# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. +BUILD_TIMEOUT_S = 300 +RUN_TIMEOUT_S = 600 + + +def node_env() -> dict[str, str]: + """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" + env = os.environ.copy() + node_modules = ROOT / "config" / "node_modules" + env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" + env["NODE_PATH"] = str(node_modules) + return env + + +def bundle_stale(bundle: Path) -> bool: + """번들이 없거나 TS 원본보다 오래됐으면 참. + + 번들이 낡으면 서버와 화면이 **다른 값**을 만든다 — 이 판정이 그것을 막는 유일한 + 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. + """ + if not bundle.is_file(): + return True + built_at = bundle.stat().st_mtime + for directory in SOURCE_DIRS: + for path in (ROOT / directory).rglob("*.ts"): + if path.stat().st_mtime > built_at: + return True + return False + + +def build_bundle(npm_script: str) -> bool: + result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 + f"npm run {npm_script}", + shell=True, + cwd=str(ROOT), + env=node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=BUILD_TIMEOUT_S, + ) + if result.returncode != 0: + logger.error("Node 번들 빌드 실패(%s):\n%s", npm_script, result.stderr) + return False + return True + + +def run_node(bundle: Path, input_path: Path, output_path: Path) -> int: + result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 + ["node", str(bundle), str(input_path), str(output_path)], + cwd=str(ROOT), + env=node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=RUN_TIMEOUT_S, + ) + if result.returncode != 0: + logger.warning( + "Node 실행 실패(%s, 끝 코드 %s): %s", bundle.name, result.returncode, result.stderr + ) + return result.returncode + + +def run_bundle_json(bundle: Path, npm_script: str, payload: dict[str, Any]) -> Any | None: + """입력을 JSON 으로 넘겨 실행하고 결과 JSON 을 돌려준다. 실패는 None. + + 결과가 작을 때만 쓸 것 — 코리도처럼 큰 산출물은 파일로 받아 그대로 옮겨야 한다. + """ + if bundle_stale(bundle) and not build_bundle(npm_script): + return None + with tempfile.TemporaryDirectory(prefix="node_bundle_") as workdir: + source = Path(workdir) / "input.json" + result = Path(workdir) / "output.json" + source.write_text(json.dumps(payload, default=float), encoding="utf-8") + if run_node(bundle, source, result) != 0: + return None + return json.loads(result.read_text(encoding="utf-8")) 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",