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/
# 코리도 서버 사전 생성 번들 — `npm run build:corridor` 산출물(2026-09-04).
# 소스가 바뀌면 서버가 스스로 다시 만든다(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}` },
/** 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",
@@ -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 사용자 확정).
+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 {
status: string;
+3 -68
View File
@@ -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)
+22
View File
@@ -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:
+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 {
const design = section.design;
@@ -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가 아예 나가지 않았다).
@@ -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<string, unknown>): 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<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>>;
type Layouts = StoredLayouts;
/**
* (+) . ** **(designTrim) B06
@@ -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";
+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",
"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",