refactor(B06): 구조물 면적·유토곡선을 브라우저 계산으로 되돌림

사용자 확정(2026-09-06): 조작 중과 [저장]·[확정]의 계산은 브라우저 몫이고,
서버는 초기값을 만들 때만 같은 코드를 Node 로 돌린다.

- 면적 산출을 B06_Section_Structure_Layouts 로 빼 Node 진입점과 브라우저가
  같은 한 벌을 쓰게 함(structureAreaRows / applyStructureAreaRows).
- [저장]·[확정]이 카드를 그리지 않은 측점까지 면적을 계산해 cross_patch 로
  보냄. 유토곡선도 그 위에서 쌓음.
- 서버는 저장 때 Node 를 돌리지 않음 — 포장 구간·세월교 노면 하강 보정만
  남기고, 그 보정은 편집분을 얹기 전에 돌게 순서를 바꿈.

검증: tsc --noEmit 통과, pytest 387 passed, Node 진입점 스모크 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 14:59:31 +09:00
co-authored by Claude Opus 5
parent a0e50d93e3
commit dd61754705
7 changed files with 164 additions and 66 deletions
+6
View File
@@ -504,4 +504,10 @@ export interface CrossSectionPatch {
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
revet_link_detached?: boolean;
revet_follow_grade?: boolean;
/** 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다
* (2026-09-06 사용자 확정). 구조물이 없는 측점에는 싣지 않는다. */
cut_area_m2?: number;
fill_area_m2?: number;
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
}
+14 -11
View File
@@ -140,6 +140,11 @@ async def _apply_section_edits(
patch["revet_link_detached"] = patch_item.revet_link_detached
if patch_item.revet_follow_grade is not None:
patch["revet_follow_grade"] = patch_item.revet_follow_grade
# 구조물 폐회로 면적 — 브라우저가 계산해 보낸 값을 그대로 정본에 얹는다.
for area_key in ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2"):
value = getattr(patch_item, area_key)
if value is not None:
patch[area_key] = value
if patch:
await merge_cross_section_design_patch(
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
@@ -147,20 +152,19 @@ 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).
async def _enforce_stored_designs(project_id: UUID, route_id: int) -> None:
"""저장분의 포장 구간·세월교 노면 하강을 바로잡는다 — 편집분을 얹기 **전에** 돈다.
브라우저가 보낸 값을 그대로 받아 적지 않는다 — 정본은 서버가 만든다
(CLAUDE.md 5장 「계산 자리」). 사용자 조작(patch)이 먼저 들어간 **뒤**에 돌아야
바뀐 벽 위치가 면적·유토곡선에 실린다. 실패는 비치명적이다.
구조물 면적·유토곡선은 브라우저가 만들어 보낸다(2026-09-06 사용자 확정). 여기서는
카드를 한 번도 안 그린 측점의 보정만 서버가 챙긴다. 실패는 비치명적이다.
"""
try:
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
from B06_Section.B06_Section_Server_Calc_Prebuild import enforce_stored_designs
await recompute_server_side(project_id, route_id)
await enforce_stored_designs(project_id, route_id)
except Exception:
logger.exception(
"횡단 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s",
"저장분 설계 보정 실패 (저장은 유지): project_id=%s route_id=%s",
project_id,
route_id,
)
@@ -209,6 +213,7 @@ async def save_sections(
request.standard_cross_section if request else None,
)
await _enforce_stored_designs(project_id, route_id)
async with pool.acquire() as connection:
await connection.begin()
try:
@@ -219,7 +224,6 @@ 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
)
@@ -283,6 +287,7 @@ async def confirm_sections(
request.standard_cross_section if request else None,
)
await _enforce_stored_designs(project_id, route_id)
async with pool.acquire() as connection:
await connection.begin()
try:
@@ -304,8 +309,6 @@ 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:
+7
View File
@@ -153,6 +153,13 @@ class CrossSectionPatch(BaseModel):
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
revet_link_detached: bool | None = None
revet_follow_grade: bool | None = None
# 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다
# (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 서버는 초기값을 만들 때만
# 같은 코드를 Node 로 돌린다(`B06_Section_Server_Calc_Node.ts`).
cut_area_m2: float | None = Field(default=None, ge=0)
fill_area_m2: float | None = Field(default=None, ge=0)
cut_soil_area_m2: float | None = Field(default=None, ge=0)
cut_rock_area_m2: float | None = Field(default=None, ge=0)
class SectionConfirmRequest(BaseModel):
+8 -45
View File
@@ -20,11 +20,10 @@
* ========================================================================== */
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";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail: SectionDetailResponse;
@@ -44,54 +43,18 @@ if (!inputPath || !outputPath) {
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);
const areas = structureAreaRows(sections);
// 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다.
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];
}
});
applyStructureAreaRows(sections, areas);
// 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존).
const conversion = input.context?.earthwork_conversion;
const result = conversion
? computeMassHaul(sections, conversion, input.context?.natural_spoil_min_ground_slope ?? undefined)
? computeMassHaul(
sections,
conversion,
input.context?.natural_spoil_min_ground_slope ?? undefined,
)
: null;
const massHaul = result
? massHaulPayload(result, computeHaulPlan(result, input.context?.haul_equipment_limits))
@@ -5,8 +5,9 @@
끊겨 지반선과 설계선이 이루는 폐회로가 달라진다.
② 그 면적을 쌓아 만드는 유토곡선.
왜 — 둘 다 화면에서만 돌아, 사용자가 B06 을 한 번도 안 열면 값이 없고 저장·확정 뒤에는
구조물을 모르는 표준값으로 되돌아갔다(CLAUDE.md 5장 「계산 자리」 — 금지 항목이던 자리).
왜 — 사용자가 B06 을 한 번도 안 열어도 **초기값**에는 이 값이 있어야 한다.
여기(Node 실행)는 **초기값 산출 전용**이다 — 사용자가 화면을 만지는 동안과 [저장]·[확정]
때의 계산은 브라우저 몫이다(2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)
으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데
@@ -81,8 +82,22 @@ def _enforce_stored_designs(
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
async def enforce_stored_designs(project_id: UUID | str, route_id: int) -> int:
"""저장분 설계의 포장 구간·세월교 노면 하강만 바로잡는다(Node 실행 없음).
[저장]·[확정]이 부른다. 구조물 면적·유토곡선은 브라우저가 만들어 보내므로 여기서
다시 만들지 않는다(2026-09-06 사용자 확정). 카드를 한 번도 안 그린 측점의 포장·
세월교 보정만 서버가 챙긴다.
"""
return await _recompute(project_id, route_id, run_node=False)
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
"""구조물 면적·유토곡선을 다시 계산해 정본에 얹는다. 고친 측점 수를 돌려준다."""
"""초기값 산출 — 위 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다."""
return await _recompute(project_id, route_id, run_node=True)
async def _recompute(project_id: UUID | str, route_id: int, *, run_node: bool) -> int:
from B06_Section.B06_Section_Router import get_section_detail
project_uuid = UUID(str(project_id))
@@ -113,16 +128,22 @@ async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
if json.dumps(item.get("design"), sort_keys=True, default=str) != before[index]
]
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
output = (
await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
)
if run_node
else {}
)
if not isinstance(output, dict):
return 0
output = {}
rows = output.get("areas")
mass_haul = output.get("mass_haul")
if not fixed and not rows and not mass_haul:
return 0
updated = 0
async with pool.acquire() as connection:
@@ -10,6 +10,7 @@
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
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";
@@ -105,3 +106,75 @@ export function trimOfLayouts(layouts: StoredLayouts) {
layouts.own?.designTrim
);
}
/** 정본에 얹는 면적 키 — 이 넷만 오간다. */
export const STRUCTURE_AREA_KEYS = [
"cut_area_m2",
"fill_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
] as const;
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
sections: readonly 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 row: 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") {
row.cut_soil_area_m2 = round(areas.cutSoilAreaM2);
row.cut_rock_area_m2 = round(areas.cutRockAreaM2);
}
return row;
}
/**
* 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다.
* 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출
* (Node 진입점)이 이 한 벌을 함께 쓴다.
*/
export function structureAreaRows(
sections: readonly CrossSection[],
): Array<Record<string, number>> {
return sections
.map((section) => areaRowOf(section, sections))
.filter((row): row is Record<string, number> => !!row);
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
rows: ReadonlyArray<Record<string, number>>,
): void {
for (const row of rows) {
const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as
Record<string, unknown> | undefined;
if (!design) continue;
for (const key of STRUCTURE_AREA_KEYS) {
if (typeof row[key] === "number") design[key] = row[key];
}
}
}
+26 -1
View File
@@ -19,6 +19,11 @@ import {
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 {
applyStructureAreaRows,
STRUCTURE_AREA_KEYS,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
@@ -167,8 +172,28 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
massHaul: Record<string, unknown> | undefined;
} {
const crossPatches = buildCrossPatches(ctx.patchSources());
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
const detail = ctx.detail();
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에
// 기대면 화면에 안 뜬 측점이 표준값으로 남는다.
if (detail) {
const areaRows = structureAreaRows(detail.cross_sections);
// 유토곡선이 **고쳐진 면적 위에서** 쌓이도록 캐시에도 얹는다(Node 진입점과 같은 순서).
applyStructureAreaRows(detail.cross_sections, areaRows);
const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch]));
for (const row of areaRows) {
let patch = byChainage.get(row.chainage_m);
if (!patch) {
patch = { chainage_m: row.chainage_m };
byChainage.set(row.chainage_m, patch);
crossPatches.push(patch);
}
for (const key of STRUCTURE_AREA_KEYS) {
if (typeof row[key] === "number") patch[key] = row[key];
}
}
}
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
const context = ctx.context();
const result =
detail && context?.earthwork_conversion