feat(B06): 노면공 카드별 포장 얹기 짝 section.pavement — 서버 · 초안 · 거울 시험(88-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
@@ -60,6 +60,7 @@ export type {
|
||||
/** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */
|
||||
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
|
||||
import type { PavementEntry } from "@util/common_util_structure_pavement";
|
||||
export type { FordPavementSpec, RevetmentSpec };
|
||||
|
||||
export interface SectionContextResponse {
|
||||
@@ -315,6 +316,8 @@ export interface CrossSection extends SectionStation {
|
||||
ford_pavement?: FordPavementSpec;
|
||||
/** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */
|
||||
revetment?: RevetmentSpec;
|
||||
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
|
||||
pavement?: PavementEntry[];
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
|
||||
@@ -77,6 +77,7 @@ from B06_Section.B06_Section_Schema import (
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_structure_pavement import attach_pavement_structures
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
@@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@@ -497,6 +499,7 @@ async def regenerate_sections(
|
||||
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
|
||||
attach_culvert_sets(project_root, result["cross_sections"])
|
||||
attach_wall_structures(project_root, result["cross_sections"])
|
||||
attach_pavement_structures(project_root, result["cross_sections"])
|
||||
return SectionDetailResponse(
|
||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||
)
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
wallSpecsFrom,
|
||||
type WallStructureInput,
|
||||
} from "@util/common_util_structure_walls";
|
||||
import {
|
||||
attachPavementSpecs,
|
||||
pavementSpecsFrom,
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch";
|
||||
@@ -65,6 +70,15 @@ async function withDraftWalls(
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
|
||||
const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id));
|
||||
if (paveTypes.size) {
|
||||
for (const section of detail.cross_sections) delete section.pavement;
|
||||
attachPavementSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes),
|
||||
);
|
||||
}
|
||||
const names = new Map(
|
||||
types
|
||||
// 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""노면공(G군) 구조물 정본 → 횡단 측점 포장 제원(`section.pavement`).
|
||||
|
||||
범위(start~end · ±0.02 m) 안 측점마다 `[{type_id, spec, thickness_m, start_m, end_m, widening_on}]`.
|
||||
두께 = `options.height_m` · 무늬 = `options.pave_spec` · 확폭 끈 구간 = `options.widening_off`("s-e,s-e").
|
||||
그림 · 정보만 — 토공 면적 · 포장 켜기(`design.paved`)는 건드리지 않음.
|
||||
|
||||
**짝**: `common_util/common_util_structure_pavement.ts` — 브라우저는 저장 전 목록으로 같은 제원을 얹음.
|
||||
거울 시험 `resources/tester/test_b06_structure_pavement_mirror.py`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값.
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
|
||||
|
||||
def _num(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
return value if math.isfinite(value) else None
|
||||
|
||||
|
||||
def widening_off_ranges(text: Any) -> list[tuple[float, float]]:
|
||||
"""「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬)."""
|
||||
ranges: list[tuple[float, float]] = []
|
||||
for part in str(text or "").split(","):
|
||||
bounds = part.split("-")
|
||||
if len(bounds) != 2 or not bounds[0].strip() or not bounds[1].strip():
|
||||
continue
|
||||
try:
|
||||
start, end = float(bounds[0]), float(bounds[1])
|
||||
except ValueError:
|
||||
continue
|
||||
if math.isfinite(start) and math.isfinite(end):
|
||||
ranges.append((min(start, end), max(start, end)))
|
||||
return ranges
|
||||
|
||||
|
||||
def pavement_specs_from(
|
||||
structures: list[dict[str, Any]], type_ids: set[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""G군 구조물 → 구간 제원. 범위가 없으면(시작 · 끝 · 기준 모두 빔) 건너뜀."""
|
||||
specs: list[dict[str, Any]] = []
|
||||
for item in structures:
|
||||
if item.get("type_id") not in type_ids:
|
||||
continue
|
||||
anchor = _num(item.get("chainage_m"))
|
||||
start = _num(item.get("start_m"))
|
||||
end = _num(item.get("end_m"))
|
||||
start = anchor if start is None else start
|
||||
end = anchor if end is None else end
|
||||
if start is None or end is None:
|
||||
continue
|
||||
options = item.get("options") or {}
|
||||
spec = options.get("pave_spec")
|
||||
specs.append(
|
||||
{
|
||||
"type_id": item["type_id"],
|
||||
"spec": spec if isinstance(spec, str) and spec else None,
|
||||
"thickness_m": _num(options.get("height_m")),
|
||||
"start_m": min(start, end),
|
||||
"end_m": max(start, end),
|
||||
"widening_off": widening_off_ranges(options.get("widening_off")),
|
||||
}
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _inside(chainage: float, start: float, end: float) -> bool:
|
||||
return start - _EDGE_TOLERANCE_M <= chainage <= end + _EDGE_TOLERANCE_M
|
||||
|
||||
|
||||
def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, Any]]) -> int:
|
||||
"""범위 안 측점에 `pavement` 목록을 얹음. 얹은 측점 수를 돌려줌."""
|
||||
attached = 0
|
||||
for section in sections:
|
||||
chainage = _num(section.get("chainage_m"))
|
||||
if chainage is None:
|
||||
continue
|
||||
found = [
|
||||
{
|
||||
"type_id": spec["type_id"],
|
||||
"spec": spec["spec"],
|
||||
"thickness_m": spec["thickness_m"],
|
||||
"start_m": spec["start_m"],
|
||||
"end_m": spec["end_m"],
|
||||
"widening_on": not any(
|
||||
_inside(chainage, start, end) for start, end in spec["widening_off"]
|
||||
),
|
||||
}
|
||||
for spec in specs
|
||||
if _inside(chainage, spec["start_m"], spec["end_m"])
|
||||
]
|
||||
if found:
|
||||
section["pavement"] = found
|
||||
attached += 1
|
||||
return attached
|
||||
|
||||
|
||||
def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명)."""
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
|
||||
try:
|
||||
type_ids = {key for key, value in structure_type_map().items() if value.group == "G"}
|
||||
structures = [item.model_dump() for item in load_structures(str(project_root))[1]]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감
|
||||
logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)")
|
||||
return 0
|
||||
return attach_pavement_specs(cross_sections, pavement_specs_from(structures, type_ids))
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* common_util_structure_pavement.ts
|
||||
* 노면공(G군) 구조물 → 횡단 측점 포장 제원(`section.pavement`).
|
||||
* 파이썬 `common_util/common_util_structure_pavement.py` 의 **짝** — 거울 시험
|
||||
* `resources/tester/test_b06_structure_pavement_mirror.py`.
|
||||
*
|
||||
* 서버는 저장분으로 · 브라우저는 저장 전 목록으로 같은 제원을 얹음.
|
||||
* 두께 = options.height_m · 무늬 = options.pave_spec · 확폭 끈 구간 = options.widening_off.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 구조물 정본 한 건(필요한 칸만) — `structures.json` 과 같은 이름. */
|
||||
export interface PavementStructureInput {
|
||||
type_id: string;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
end_m?: number | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 측점에 얹는 포장 한 건 — `section.pavement[]`. */
|
||||
export interface PavementEntry {
|
||||
type_id: string;
|
||||
/** 포장사양 — 콘크리트 · 아스콘 · 쇄석(비면 표준 그림). */
|
||||
spec: string | null;
|
||||
/** 두께(m) — 노면선 아래로. 비면 표준 횡단 포장 두께. */
|
||||
thickness_m: number | null;
|
||||
start_m: number;
|
||||
end_m: number;
|
||||
/** false = 확폭 끈 구간(규격 폭 끝까지만). */
|
||||
widening_on: boolean;
|
||||
}
|
||||
|
||||
export interface PavementSpec extends Omit<PavementEntry, "widening_on"> {
|
||||
widening_off: Array<[number, number]>;
|
||||
}
|
||||
|
||||
/** 파이썬 `_EDGE_TOLERANCE_M` — C군 벽과 같은 값. */
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬). */
|
||||
export function wideningOffRanges(text: unknown): Array<[number, number]> {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
for (const part of String(text ?? "").split(",")) {
|
||||
const bounds = part.split("-");
|
||||
if (bounds.length !== 2 || !bounds[0].trim() || !bounds[1].trim()) continue;
|
||||
const start = Number(bounds[0]);
|
||||
const end = Number(bounds[1]);
|
||||
if (Number.isFinite(start) && Number.isFinite(end))
|
||||
ranges.push([Math.min(start, end), Math.max(start, end)]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** G군 구조물 → 구간 제원(파이썬 `pavement_specs_from`). */
|
||||
export function pavementSpecsFrom(
|
||||
structures: readonly PavementStructureInput[],
|
||||
typeIds: ReadonlySet<string>,
|
||||
): PavementSpec[] {
|
||||
const specs: PavementSpec[] = [];
|
||||
for (const item of structures) {
|
||||
if (!typeIds.has(item.type_id)) continue;
|
||||
const anchor = num(item.chainage_m);
|
||||
const start = num(item.start_m) ?? anchor;
|
||||
const end = num(item.end_m) ?? anchor;
|
||||
if (start === null || end === null) continue;
|
||||
const options = (item.options ?? {}) as Record<string, unknown>;
|
||||
const spec = options.pave_spec;
|
||||
specs.push({
|
||||
type_id: item.type_id,
|
||||
spec: typeof spec === "string" && spec ? spec : null,
|
||||
thickness_m: num(options.height_m),
|
||||
start_m: Math.min(start, end),
|
||||
end_m: Math.max(start, end),
|
||||
widening_off: wideningOffRanges(options.widening_off),
|
||||
});
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
const inside = (chainage: number, start: number, end: number): boolean =>
|
||||
start - EDGE_TOLERANCE_M <= chainage && chainage <= end + EDGE_TOLERANCE_M;
|
||||
|
||||
/** 범위 안 측점에 `pavement` 목록을 얹음(파이썬 `attach_pavement_specs`). 얹은 측점 수. */
|
||||
export function attachPavementSpecs(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
specs: readonly PavementSpec[],
|
||||
): number {
|
||||
let attached = 0;
|
||||
for (const section of sections) {
|
||||
const chainage = num(section.chainage_m);
|
||||
if (chainage === null) continue;
|
||||
const found: PavementEntry[] = specs
|
||||
.filter((spec) => inside(chainage, spec.start_m, spec.end_m))
|
||||
.map((spec) => ({
|
||||
type_id: spec.type_id,
|
||||
spec: spec.spec,
|
||||
thickness_m: spec.thickness_m,
|
||||
start_m: spec.start_m,
|
||||
end_m: spec.end_m,
|
||||
widening_on: !spec.widening_off.some(([start, end]) => inside(chainage, start, end)),
|
||||
}));
|
||||
if (found.length) {
|
||||
section.pavement = found;
|
||||
attached += 1;
|
||||
}
|
||||
}
|
||||
return attached;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험.
|
||||
|
||||
`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가
|
||||
같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
from common_util.common_util_structure_pavement import ( # noqa: E402
|
||||
attach_pavement_specs,
|
||||
pavement_specs_from,
|
||||
)
|
||||
|
||||
STRUCTURES = [
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"placement": "interval",
|
||||
"chainage_m": None,
|
||||
"start_m": 100.0,
|
||||
"end_m": 160.0,
|
||||
"options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"},
|
||||
},
|
||||
{
|
||||
# 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로.
|
||||
"type_id": "gravel_surfacing",
|
||||
"placement": "interval",
|
||||
"start_m": 150.0,
|
||||
"end_m": 130.0,
|
||||
"options": {"pave_spec": "", "height_m": "0.3"},
|
||||
},
|
||||
{"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}},
|
||||
{"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}},
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"chainage_m": 300.0,
|
||||
"options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"},
|
||||
},
|
||||
]
|
||||
TYPE_IDS = ["pavement_concrete", "gravel_surfacing"]
|
||||
CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js";
|
||||
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const sections = input.chainages.map((chainage_m) => ({ chainage_m }));
|
||||
attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids)));
|
||||
writeFileSync(process.argv[3], JSON.stringify(sections));
|
||||
"""
|
||||
|
||||
|
||||
def _python_result() -> list[dict]:
|
||||
sections = [{"chainage_m": value} for value in CHAINAGES]
|
||||
attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS)))
|
||||
return sections
|
||||
|
||||
|
||||
def _ts_result(tmp_path: Path) -> list[dict]:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(
|
||||
json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_pavement_specs_mirror():
|
||||
with tempfile.TemporaryDirectory() as workdir:
|
||||
ts = _ts_result(Path(workdir))
|
||||
py = _python_result()
|
||||
assert ts == py
|
||||
by = {section["chainage_m"]: section.get("pavement") for section in py}
|
||||
# 범위 밖 · 경계 오차(0.02 m) 안
|
||||
assert by[99.97] is None and by[160.05] is None and by[400.0] is None
|
||||
assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"]
|
||||
assert by[160.02][0]["thickness_m"] == 0.25
|
||||
# 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림
|
||||
assert by[119.99][0]["widening_on"] is False
|
||||
assert by[100.0][0]["widening_on"] is True
|
||||
# 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null
|
||||
assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"]
|
||||
gravel = by[145.0][1]
|
||||
assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0)
|
||||
assert gravel["thickness_m"] is None and gravel["spec"] is None
|
||||
# 기준 측점만 있는 옛 항목 = 그 측점 한 점
|
||||
assert by[300.0] == [
|
||||
{
|
||||
"type_id": "pavement_concrete",
|
||||
"spec": "아스콘",
|
||||
"thickness_m": 0.05,
|
||||
"start_m": 300.0,
|
||||
"end_m": 300.0,
|
||||
"widening_on": False,
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user