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:
2026-09-06 14:20:54 +09:00
parent dc52d55988
commit 1f3b30e698
15 changed files with 509 additions and 174 deletions
+3 -1
View File
@@ -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/
+3 -2
View File
@@ -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 사용자 확정).
+13
View File
@@ -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;
+3 -68
View File
@@ -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)
+22
View File
@@ -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:
+100
View File
@@ -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
);
}
+2 -2
View File
@@ -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";
+103
View File
@@ -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
View File
@@ -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",