@
feat(B06): 구조물 면적·유토곡선 서버 계산 자리 마련 + 상단측 저장 누락 수정 계산 자리 일원화(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) <noreply@anthropic.com> @
This commit is contained in:
+3
-1
@@ -57,4 +57,6 @@ tmp/
|
|||||||
**/.obsidian/
|
**/.obsidian/
|
||||||
# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04).
|
# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04).
|
||||||
# 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py).
|
# 소스가 바뀌면 서버가 스스로 다시 만든다(B05_Profile_Corridor_Prebuild.py).
|
||||||
config/corridor_node/
|
config/corridor_node/
|
||||||
|
# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06).
|
||||||
|
config/server_calc_node/
|
||||||
@@ -92,8 +92,9 @@ export const STATE_REGISTRY = {
|
|||||||
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
||||||
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
||||||
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
|
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
|
||||||
/** 관 위치(되돌리기 스냅샷이 함께 본다). */
|
/* `pipes`(옛 `b05:pipes`)는 2026-09-06 에 뺐다 — 읽는 곳도 쓰는 곳도 없었다.
|
||||||
pipes: { bucket: "draft", scope: "project", legacy: () => "b05:pipes" },
|
관 위치의 정본은 `pipe_points.json` 이고, 화면은 [저장] 때 `savePipes()` 로 바로
|
||||||
|
내보낸다. 초안처럼 보이는 이름만 남아 대응표에서 「저장 자리 없음」으로 잡혔다. */
|
||||||
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
||||||
"structure-pick": {
|
"structure-pick": {
|
||||||
bucket: "draft",
|
bucket: "draft",
|
||||||
|
|||||||
@@ -280,6 +280,17 @@ async def run_auto_design_chain(
|
|||||||
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
||||||
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
|
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장).
|
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
||||||
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
||||||
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
const stored = readState<Record<string, "left" | "right">>("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 {
|
export interface RouteResetResponse {
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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_Repository import get_route_points
|
||||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
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
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js"
|
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:
|
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)
|
logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id)
|
||||||
return False
|
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
|
return False
|
||||||
|
|
||||||
target = corridor_path(Path(project_root), route_id)
|
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),
|
json.dumps({"detail": detail, "route_points": points}, default=float),
|
||||||
"utf-8",
|
"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
|
return False
|
||||||
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
|
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
|
||||||
await asyncio.to_thread(_replace, built, target)
|
await asyncio.to_thread(_replace, built, target)
|
||||||
|
|||||||
@@ -147,6 +147,25 @@ async def _apply_section_edits(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse)
|
@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(
|
async def save_sections(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
route_id: int,
|
route_id: int,
|
||||||
@@ -200,6 +219,7 @@ async def save_sections(
|
|||||||
except Exception:
|
except Exception:
|
||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
raise
|
raise
|
||||||
|
await _recompute_server_side(project_id, route_id)
|
||||||
return SectionConfirmResponse(
|
return SectionConfirmResponse(
|
||||||
project_id=str(project_id), route_id=route_id, confirmed=False
|
project_id=str(project_id), route_id=route_id, confirmed=False
|
||||||
)
|
)
|
||||||
@@ -284,6 +304,8 @@ async def confirm_sections(
|
|||||||
await connection.rollback()
|
await connection.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
await _recompute_server_side(project_id, route_id)
|
||||||
|
|
||||||
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
|
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
|
||||||
# 파일 기반·비치명적: 실패해도 확정은 유지한다.
|
# 파일 기반·비치명적: 실패해도 확정은 유지한다.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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<typeof computeMassHaul>[1];
|
||||||
|
natural_spoil_min_ground_slope?: number | null;
|
||||||
|
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[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<string, number> | 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<string, number> = {
|
||||||
|
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<string, number> => !!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<string, unknown>)[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 }));
|
||||||
@@ -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
|
||||||
@@ -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<WallAdjust>) } : { ...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<ReturnType<typeof computeStoredLayouts>>;
|
||||||
|
|
||||||
|
/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */
|
||||||
|
export function trimOfLayouts(layouts: StoredLayouts) {
|
||||||
|
return (
|
||||||
|
layouts.culvert?.designTrim ??
|
||||||
|
layouts.ford?.designTrim ??
|
||||||
|
layouts.box?.designTrim ??
|
||||||
|
layouts.own?.designTrim
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void {
|
||||||
const design = section.design;
|
const design = section.design;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type SectionDetailResponse,
|
type SectionDetailResponse,
|
||||||
} from "./B06_Section_Api_Fetch";
|
} from "./B06_Section_Api_Fetch";
|
||||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
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 { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||||
@@ -194,6 +195,11 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string):
|
|||||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||||
await ctx.flushCulvertOptions();
|
await ctx.flushCulvertOptions();
|
||||||
|
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
|
||||||
|
// 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다
|
||||||
|
// (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래
|
||||||
|
// 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다.
|
||||||
|
await flushUphillOverrides(projectId).catch(() => undefined);
|
||||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||||
|
|||||||
@@ -14,40 +14,19 @@
|
|||||||
|
|
||||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
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 { 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 { 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 { 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 { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||||||
import {
|
import {
|
||||||
appendCrossDesignOverlay,
|
appendCrossDesignOverlay,
|
||||||
appendPavementOverlay,
|
appendPavementOverlay,
|
||||||
} from "../B06_Section/B06_Section_UI_Cross_Design";
|
} from "../B06_Section/B06_Section_UI_Cross_Design";
|
||||||
import {
|
import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment";
|
||||||
appendRevetmentOverlay,
|
|
||||||
computeRevetmentLayout,
|
|
||||||
} from "../B06_Section/B06_Section_UI_Cross_Revetment";
|
|
||||||
|
|
||||||
/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */
|
/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */
|
||||||
export interface CrossPlacement {
|
export interface CrossPlacement {
|
||||||
@@ -440,77 +419,7 @@ function entityBox(entity: Record<string, unknown>): number[] | null {
|
|||||||
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
|
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
type Layouts = StoredLayouts;
|
||||||
// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
|
|
||||||
const stored = section.design?.revet_adjust?.[role];
|
|
||||||
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...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<ReturnType<typeof computeLayouts>>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
|
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
* 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이
|
* 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이
|
||||||
* 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
|
* 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
|
||||||
*
|
*
|
||||||
* ⚠ 파이썬 짝이 아직 없다 — 서버는 구조물 기하를 모른다(계획서 3-2). 그래서 이 보정은
|
* 파이썬 짝은 만들지 않는다 — 서버도 **이 파일을 그대로 실행**한다
|
||||||
* **브라우저 계산에만** 실린다. 저장·확정 뒤 서버가 다시 계산하면 표준 설계선 값으로
|
* (`B06_Section_Structure_Areas_Node.ts` → 번들, 2026-09-06). 전처리 체인 끝과
|
||||||
* 돌아간다. 서버 쪽 반영은 별도 작업이다.
|
* [저장]·[확정] 때 서버가 돌려 정본에 얹으므로, 브라우저를 한 번도 안 열어도 값이 선다.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas";
|
||||||
|
|||||||
@@ -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"))
|
||||||
+2
-1
@@ -5,8 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
|
"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: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",
|
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
|
||||||
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
|
"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",
|
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||||
|
|||||||
Reference in New Issue
Block a user