feat(구조물표): 323-4 형상 얹기 한 벌(TS · 파이썬 짝) — 저장분 · 초안 · 미리 뜬 횡단 같은 길(저장 전 그림 = 저장 뒤 그림) · 빈칸 경고 한 꼴(카드 칩 「종류 칸 빈칸」) · 3D 지문 = 그림 자료 통째 · C군 벽 3D 한 번 · 소단 원천 = 표 형상 berm

- 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」)
- 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침
- 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈
- 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`)
- 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row)
- 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-14 17:26:53 +09:00
co-authored by Claude Opus 5.5
parent 3d8c161efa
commit c45814f910
34 changed files with 832 additions and 437 deletions
@@ -20,7 +20,7 @@
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
* ========================================================================== */
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash";
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import {
@@ -286,7 +286,10 @@ export function buildCorridorStructures(
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
// C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것)
const wallSweep = wallSweepAt(section, crossSections);
const revetLayout =
wallSweep &&
ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) &&
builds(section.revetment?.type_id, "wall")
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
@@ -364,18 +367,10 @@ export function buildCorridorStructures(
});
};
if (revetLayout) {
if (revetLayout && wallSweep) {
// 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다.
for (const tier of revetLayout.tiers) {
pushSwept(
"revet",
tier.polygon,
revetLayout.span.beforeM,
revetLayout.span.afterM,
null,
"own",
);
}
for (const tier of revetLayout.tiers)
pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own");
}
/**
@@ -1,141 +1,79 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Hash.ts
* 저장 코리도 만료 판정용 **구조물 해시 요약**.
* 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4).
*
* `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02).
* 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 —
* 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것.
* 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을
* 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음.
* 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다.
* ========================================================================== */
import type {
ChuteSpec,
CrossSection,
CulvertSideSpec,
} from "../B06_Section/B06_Section_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys";
/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */
function chuteParts(chute: ChuteSpec | null | undefined): Array<string | number> {
if (!chute) return [];
// 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로
const base = ["ch", chute.structure, chute.thickness_m];
return chute.length_m ? [...base, chute.length_m] : base;
}
/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */
const ADJUST_KEYS = [
"revet_adjust",
"box_adjust",
"ford_adjust",
"basin_adjust",
"extra_wall_counts",
"extra_spans",
"revet_follow_grade",
"revet_link_detached",
"inlet_structure",
] as const;
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> {
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
const extras: Array<string | number> = [];
const fordPave = section.ford_pavement;
if (fordPave) {
extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? "");
extras.push(...chuteParts(fordPave.chute));
}
const ownRevet = section.revetment;
if (ownRevet) {
extras.push(
"rv",
ownRevet.start_m,
ownRevet.end_m,
ownRevet.anchor_m,
ownRevet.height_m ?? "",
ownRevet.side ?? "",
ownRevet.form ?? "",
ownRevet.tiers ?? "",
ownRevet.lift_m ?? "",
ownRevet.shift_m ?? "",
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
);
}
const box = section.box;
// 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음.
if (box) {
extras.push(
"bx",
box.inner_width_m,
box.inner_height_m,
box.span_m,
box.cover_m,
box.wing_in.slab_extend_m,
box.wing_in.height_m ?? "",
box.wing_out.slab_extend_m,
box.wing_out.height_m ?? "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
...(["left", "right"] as const).map((side) => {
const value = section.design?.box_adjust?.[side];
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
}),
...chuteParts(box.chute),
);
}
const ford = section.ford;
if (ford) {
const adjust = section.design?.ford_adjust;
const wall = (role: "inlet" | "outlet"): string => {
const value = adjust?.[role];
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
};
extras.push(`dr:${adjust?.dropM ?? ""}`);
extras.push(
"fd",
ford.diameter_m,
ford.pipe_count,
ford.span_m,
ford.wing_in.slab_extend_m,
ford.wing_out.slab_extend_m,
wall("inlet"),
wall("outlet"),
...chuteParts(ford.chute),
);
}
const culvert = section.culvert;
if (!culvert) return extras;
const side = (spec: CulvertSideSpec): string =>
[
spec.structure,
spec.revet_form ?? "",
spec.revet_height_m ?? "",
spec.revet_length_m ?? "",
spec.revet_before_m ?? "",
spec.revet_after_m ?? "",
spec.basin_length_m ?? "",
spec.basin_before_m ?? "",
spec.basin_after_m ?? "",
spec.basin_height_m ?? "",
].join(",");
const basin = section.design?.basin_adjust;
const revet = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */
const stable = (value: unknown): string =>
JSON.stringify(value, (_key, item: unknown) =>
item && typeof item === "object" && !Array.isArray(item)
? Object.fromEntries(
Object.keys(item)
.sort()
.map((key) => [key, (item as Record<string, unknown>)[key]]),
)
: item,
);
/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */
export function structureHashParts(section: CrossSection): string[] {
const record = section as unknown as Record<string, unknown>;
const shapes = SHAPE_KEYS.filter((key) => record[key] != null);
if (!shapes.length) return [];
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
return [
...extras,
"cv",
culvert.diameter_m,
culvert.pipe_kind ?? "",
side(culvert.inlet),
side(culvert.outlet),
section.design?.inlet_structure ?? "",
basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
revet
? Object.keys(revet)
.sort()
.map((role) => {
const value = revet[role];
return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`;
})
.join("|")
: "",
counts ? `${counts.outlet},${counts.basin}` : "",
section.design?.revet_follow_grade === false ? "flatZ" : "",
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
Object.entries(section.design?.extra_spans ?? {})
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
.join("|"),
section.design?.revet_link_detached === true ? "detached" : "",
...chuteParts(culvert.chute),
stable({
...Object.fromEntries(shapes.map((key) => [key, record[key]])),
adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])),
}),
];
}
/**
* C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 ·
* 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것).
*/
export function wallSweepAt(
section: CrossSection,
sections: readonly CrossSection[],
): { beforeM: number; afterM: number } | null {
const wall = section.revetment;
if (!wall) return null;
const same = (other: CrossSection): boolean =>
!!other.revetment &&
(wall.structure_id
? other.revetment.structure_id === wall.structure_id
: other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m);
const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m);
const owner = sections
.filter(same)
.reduce<CrossSection | null>(
(best, other) => (!best || gap(other) < gap(best) ? other : best),
null,
);
if (owner !== section) return null;
return {
beforeM: Math.max(section.chainage_m - wall.start_m, 0),
afterM: Math.max(wall.end_m - section.chainage_m, 0),
};
}
+2
View File
@@ -339,6 +339,8 @@ export interface CrossSection extends SectionStation {
pavement?: PavementEntry[];
/** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */
underdrain?: UnderdrainSpec;
/** 빈칸 경고(323-4 · 읽기 칸 · 저장 안 함) — 「종류 칸」(형상 필수 칸이 빈 관 · 구조물 · 측점 계획 자리). */
blank?: string[];
}
export interface SectionDetailResponse {
+105
View File
@@ -0,0 +1,105 @@
"""형상 얹기 한 벌(323-4) — 측점(횡단 dict) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 →
빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀.
TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) ·
거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import (
attach_culvert_specs,
culvert_sets_from,
pipe_points_file,
)
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from
from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from
from common_util.common_util_drainage_pipes import read_pipe_points_file
from common_util.common_util_structure_catalog import catalog_row, field_name, type_ids_with
from common_util.common_util_structure_pavement import (
attach_pavement_specs,
pavement_specs_from,
)
from common_util.common_util_structure_station import (
SAME_STATION_M,
blank_keys,
plan_stations,
)
logger = logging.getLogger(__name__)
#: 측점 그림 자료 열쇠 — TS `common_util_structure_shape_keys.SHAPE_KEYS` 짝.
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
def _blank_labels(type_id: str, options: dict | None, types: dict[str, Any]) -> list[str]:
"""빈 필수 칸 → 「종류 이름 칸 이름」(TS `blankLabels` 짝)."""
name = (catalog_row(type_id) or {}).get("name") or type_id
ordered = [types[type_id]] if type_id in types else []
ordered += list(types.values())
out = []
for key in blank_keys(type_id, options):
label = next(
(o.label for t in ordered for o in t.options if o.key == key and o.label), None
)
out.append(f"{name} {label or field_name(key) or key}")
return out
def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
"""빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(TS `attachBlanks` 짝)."""
types = structure_type_map()
owners: dict[str, tuple[str, dict | None]] = {}
for pipe in pipes:
owner = getattr(pipe, "id", None) or f"pipe-{float(pipe.chainage_m):.2f}"
owners[owner] = (pipe.facility or "pipe", pipe.options)
for item in structures:
if item.structure_id:
owners[item.structure_id] = (item.type_id, item.options)
for station in plan_stations(pipes, structures):
if not station["blank"]:
continue
labels: list[str] = []
for owner in station["owners"]:
hit = owners.get(owner)
for label in _blank_labels(hit[0], hit[1], types) if hit else []:
if label not in labels:
labels.append(label)
for section in sections:
at = section.get("chainage_m")
if isinstance(at, (int, float)) and abs(at - station["chainage_m"]) < SAME_STATION_M:
section["blank"] = labels
def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None:
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침)."""
for section in sections:
for key in (*SHAPE_KEYS, "blank"):
section.pop(key, None)
attach_culvert_specs(sections, culvert_sets_from(pipes))
# 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음
attach_wall_specs(sections, wall_specs_from(structures))
attach_pavement_specs(
sections,
pavement_specs_from([s.model_dump() for s in structures], type_ids_with(shape="pavement")),
)
attach_underdrain_specs(sections, underdrains_from(structures))
_attach_blanks(sections, pipes, structures)
def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None:
"""저장분 현황(관 지점 · 구조물 정본)으로 얹음 — 구조물 정본을 못 읽으면 관만(횡단 조회는 이어 감)."""
pipes = read_pipe_points_file(pipe_points_file(project_root))
try:
structures = load_structures(str(project_root))[1]
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
logger.exception("B06 구조물 정본을 읽지 못했습니다 (관만 얹음)")
structures = []
attach_shapes(sections, pipes, structures)
+13 -2
View File
@@ -604,8 +604,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
except (OSError, json.JSONDecodeError):
logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return {}
return culvert_sets_from(parse_pipe_points(document.get("points")))
def culvert_sets_from(points: list[Any]) -> dict[float, dict[str, Any]]:
"""관 지점 목록 → 누가거리(소수 2자리)별 세트 제원(TS `buildCulvertSets` 짝 · 형상 얹기 한 벌 323-4)."""
sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")):
for point in points:
# 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝)
shape = (catalog_row(point.facility) or {}).get("shape")
if shape == "pipe_set":
@@ -631,7 +636,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다."""
sets = load_culvert_sets(project_root)
return attach_culvert_specs(cross_sections, load_culvert_sets(project_root))
def attach_culvert_specs(
cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]]
) -> int:
"""세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다."""
if not sets:
return 0
attached = 0
@@ -66,59 +66,60 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
]
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
BERM_TYPE_ID = "berm"
def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"""구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록.
소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9).
"""
try:
types = structure_type_map()
found: list[dict[str, Any]] = []
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
continue
start, end = structure.start_m, structure.end_m
if start is None or end is None:
continue
options = structure.options or {}
extras = tier_extras(structure.type_id, options)
found.append(
{
"structure_id": structure.structure_id,
"type_id": structure.type_id,
"name": definition.name,
"start_m": float(min(start, end)),
"end_m": float(max(start, end)),
"anchor_m": float(structure.anchor_m()),
"form": options.get("form") or _wall_form(structure.type_id),
"height_m": options.get("height_m"),
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
"side": options.get("side"),
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
"foundation": options.get("foundation"),
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
"tiers": len(extras) + 1 if extras else options.get("tiers"),
"extras": extras,
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
return wall_specs_from(load_structures(str(project_root))[1])
except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다
logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)")
return []
def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]:
"""구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
types = structure_type_map()
found: list[dict[str, Any]] = []
for structure in structures:
definition = types.get(structure.type_id)
if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall":
continue
start, end = structure.start_m, structure.end_m
if start is None or end is None:
continue
options = structure.options or {}
extras = tier_extras(structure.type_id, options)
found.append(
{
"structure_id": structure.structure_id,
"type_id": structure.type_id,
"name": definition.name,
"start_m": float(min(start, end)),
"end_m": float(max(start, end)),
"anchor_m": float(structure.anchor_m()),
"form": options.get("form") or _wall_form(structure.type_id),
"height_m": options.get("height_m"),
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
"side": options.get("side"),
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
"foundation": options.get("foundation"),
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
"tiers": len(extras) + 1 if extras else options.get("tiers"),
"extras": extras,
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
@@ -142,7 +143,7 @@ def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, fl
try:
spans: list[tuple[float, float, dict[str, float]]] = []
for item in load_structures(str(project_root))[1]:
if item.type_id != BERM_TYPE_ID:
if (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4)
continue
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
start = item.start_m if item.start_m is not None else anchor
@@ -190,7 +191,14 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다.
한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫).
"""
walls = load_wall_structures(project_root)
attached = attach_wall_specs(cross_sections, load_wall_structures(project_root))
if attached:
logger.info("B06 구조물 벽 %d개 측점에 얹음", attached)
return attached
def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int:
"""구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다."""
if not walls:
return 0
attached = 0
@@ -209,6 +217,4 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An
section["revetment"] = spec
attached += 1
break
if attached:
logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls))
return attached
+12 -1
View File
@@ -42,6 +42,11 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]:
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)")
return []
return underdrains_from(structures)
def underdrains_from(structures: list[Any]) -> list[dict[str, Any]]:
"""구조물 목록의 맹암거 제원(TS `underdrainSpecsFrom` 짝 · 형상 얹기 한 벌 323-4)."""
found: list[dict[str, Any]] = []
for structure in structures:
if (catalog_row(structure.type_id) or {}).get("shape") != "underdrain":
@@ -85,7 +90,13 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
drains = load_underdrains(project_root)
return attach_underdrain_specs(cross_sections, load_underdrains(project_root))
def attach_underdrain_specs(
cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]]
) -> int:
"""기준 측점에 맹암거 제원을 얹는다(TS `attachUnderdrainSpecs` 짝). 얹은 개수를 돌려준다."""
attached = 0
for section in cross_sections:
chainage = _number(section.get("chainage_m"))
+6 -17
View File
@@ -19,14 +19,13 @@ from B05_Profile.B05_Profile_Engine_Sections import (
run_section_generation,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
from B06_Section.B06_Section_Engine_Design import (
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
)
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
from B06_Section.B06_Section_Repository import (
count_cross_sections,
create_longitudinal_section,
@@ -87,7 +86,6 @@ 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
@@ -278,15 +276,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
isinstance(section, dict) for section in cross_sections
):
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
attach_culvert_sets(root, cross_sections)
# 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고) = 형상 얹기 한 벌(323-4 · 화면 짝 attachShapes)
attach_saved_shapes(root, cross_sections)
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
attach_wall_structures(root, cross_sections)
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
attach_underdrains(root, cross_sections) # 맹암거 형상(288-1)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -446,11 +438,8 @@ async def regenerate_sections(
await connection.rollback()
raise
result = sections["result"]
# 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
attach_wall_structures(project_root, result["cross_sections"])
attach_pavement_structures(project_root, result["cross_sections"])
attach_underdrains(project_root, result["cross_sections"])
# 재생성 응답도 상세 조회와 같은 형상 얹기 한 벌(323-4) — 화면이 어긋나지 않게.
attach_saved_shapes(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
+43 -33
View File
@@ -8,8 +8,8 @@
* 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음.
*
* 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) ·
* 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도
* 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점
* 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3).
* ========================================================================== */
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
@@ -21,11 +21,8 @@ import {
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import {
attachCulvertSets,
buildCulvertSets,
registryDefaults,
} from "@util/common_util_culvert_sets";
import { registryDefaults } from "@util/common_util_culvert_sets";
import { attachShapes } from "@util/common_util_structure_attach";
import {
pipeStationMarks,
planStations,
@@ -119,14 +116,8 @@ function bareOf(section: CrossSection): CrossSection {
return copy;
}
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */
function decorate(
fresh: CrossSection[],
pipes: Pipes,
planned: readonly PlannedStation[],
types: StructureType[],
): void {
const registry = registryDefaults(types);
/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */
function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void {
for (const section of fresh) {
section.preview = true;
// 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저)
@@ -134,18 +125,41 @@ function decorate(
if (hit) section.structure = hit.title;
else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217)
}
const own = pipes.filter((pipe) =>
fresh.some((section) => near(section.chainage_m, pipe.chainage_m)),
);
// 미리 뜬 횡단은 한 벌을 기억해 다시 씀 — 얹기 전에 관 계열 제원을 비움(앞 종류 제원이 남아
// 겹치던 것 · 서버가 얹어 온 저장분 종류도 · 열쇠 = `Section_Store` FACILITY_KEYS 와 같음 · 317-3)
for (const section of fresh as unknown as Array<Record<string, unknown>>) {
for (const key of ["culvert", "ford", "box", "ford_pavement"]) delete section[key];
}
attachCulvertSets(
fresh as unknown as Array<Record<string, unknown>>,
buildCulvertSets(own, registry),
);
}
/** 현황 = 종류마다 초안 ?? 저장분 + 종류 표 — 측점 계획 · 형상 얹기가 같은 것(못 받으면 null). */
function statusOf(
projectId: string,
pipesDraft: Pipes | null,
structuresDraft: StructureInstance[] | null,
): Promise<[Pipes, StructureInstance[], StructureType[]] | null> {
return Promise.all([
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
fetchStructureTypes(true),
]).catch(() => null);
}
/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */
const drafted = new WeakSet<SectionDetailResponse>();
/**
* 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 ·
* 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로.
*/
export async function withDraftShapes(
detail: SectionDetailResponse,
projectId: string,
): Promise<void> {
const pipesDraft = readPendingPipes(projectId);
const structuresDraft = readPendingStructures(projectId);
const drafts = !!(pipesDraft || structuresDraft);
if (!drafts && !drafted.has(detail)) return;
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
if (!inputs) return;
attachShapes(detail.cross_sections as unknown as Array<Record<string, unknown>>, ...inputs);
if (drafts) drafted.add(detail);
else drafted.delete(detail);
}
/**
@@ -170,11 +184,7 @@ export async function withDraftStations(
let stale: CrossSection[] = [];
let fresh: CrossSection[] = [];
if (pipesDraft || structuresDraft) {
const inputs = await Promise.all([
pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes),
structuresDraft ?? savedOf(projectId).then(([, structures]) => structures),
fetchStructureTypes(true),
]).catch(() => null);
const inputs = await statusOf(projectId, pipesDraft, structuresDraft);
if (inputs) {
const [pipes, structures, types] = inputs;
const planned = planStations(pipes, structures, types, registryDefaults(types));
@@ -226,7 +236,7 @@ export async function withDraftStations(
live.every((section) => !near(section.chainage_m, at)),
);
fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : [];
if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, types);
if (fresh.length || bare.length) decorate([...fresh, ...bare], planned);
}
}
if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음
+9 -142
View File
@@ -18,35 +18,13 @@
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import {
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import {
attachCulvertSets,
buildCulvertSets,
registryDefaults,
} from "@util/common_util_culvert_sets";
import {
attachWallSpecs,
wallSpecsFrom,
type WallStructureInput,
} from "@util/common_util_structure_walls";
import {
attachPavementSpecs,
pavementSpecsFrom,
type PavementStructureInput,
} from "@util/common_util_structure_pavement";
import { setGeneralStations } from "../common_util/common_util_structure_station";
import { cardTitle, catalogRow } from "../common_util/common_util_structure_catalog";
import {
attachUnderdrainSpecs,
underdrainSpecsFrom,
type UnderdrainInput,
} from "../common_util/common_util_structure_underdrain";
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
forgetDraftStations,
withDraftShapes,
withDraftStations,
} from "./B06_Section_Section_Drafts";
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";
@@ -61,12 +39,11 @@ function keyOf(projectId: string, routeId: number): string {
}
/**
* 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다.
* 초안(관 · 구조물)을 상세에 얹는다 — **여기가 유일한 자리**다.
*
* 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의
* 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다.
* 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다
* (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`).
* 서버는 저장분만 얹어 준다. 초안이 있으면 그것이 화면의 정본이므로, 캐시·세션·새 요청 어느 길로
* 들어오든 이 함수를 지나 같은 상태가 된다(2026-09-06 실측). 측점 = 측점 계획 한 벌(`withDraftStations`) ·
* 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` · 서버 `attach_shapes` 짝 · 323-4).
*/
async function withDraftWalls(
detail: SectionDetailResponse,
@@ -75,109 +52,10 @@ async function withDraftWalls(
): Promise<SectionDetailResponse> {
setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1)
await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2)
const drafts = readPendingStructures(projectId);
// 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312).
// 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는
// 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴.
await withDraftPipeKinds(detail, projectId);
if (!drafts) return withStructureAreas(detail);
// 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`)
attachUnderdrainSpecs(
detail.cross_sections as unknown as Array<Record<string, unknown>>,
underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]),
);
// 표 형상(322-1) — 「사용 중」 거르기 없음(꺼진 종류도 그 그림대로 · 쇄석 포장층)
const types = await fetchStructureTypes(true).catch(() => []);
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
const paveTypes = new Set(shaped("pavement").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),
);
}
// 벽 = 표 형상 wall(소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 자리에 안 얹음 · 계획서 3-9)
const names = new Map(shaped("wall").map((type) => [type.type_id, type.name] as const));
if (!names.size) return detail;
for (const section of detail.cross_sections) {
if (section.revetment) delete (section as { revetment?: unknown }).revetment;
}
attachWallSpecs(
detail.cross_sections as unknown as Array<Record<string, unknown>>,
wallSpecsFrom(drafts as unknown as WallStructureInput[], names),
);
await withDraftShapes(detail, projectId);
return withStructureAreas(detail);
}
/** 관 지점 시설 → 횡단 자리 키 = 표 형상(파이썬 `_SECTION_KEYS` · 배관 세트 · 독립 기슭막이 = culvert). */
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
const SHAPE_KEY: Record<string, (typeof FACILITY_KEYS)[number]> = {
box: "box",
ford: "ford",
ford_pavement: "ford_pavement",
};
const shapeOf = (facility: string | null | undefined): string =>
catalogRow(facility ?? "pipe")?.shape ?? "pipe_set";
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
SHAPE_KEY[shapeOf(facility)] ?? "culvert";
/**
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
*/
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
const pipes = readPendingPipes(projectId);
if (!pipes) return;
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
// 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314)
for (const section of sections) {
const spec = section.ford_pavement as Record<string, unknown> | undefined;
const kept = (pipe: (typeof pipes)[number]) =>
shapeOf(pipe.facility) === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m);
if (spec && !pipes.some(kept)) delete section.ford_pavement;
}
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
key === facilityKey(pipe.facility ?? "pipe") &&
(key !== "culvert" ||
(spec.hidden_pipe === true) === (shapeOf(pipe.facility) === "hidden_revet"));
const stale = pipes.filter((pipe) => {
const here = sections.flatMap((section) =>
FACILITY_KEYS.flatMap((key) => {
const spec = section[key] as Record<string, unknown> | undefined;
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
}),
);
// 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314)
if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";
return !here.some(([key, spec]) => fits(pipe, key, spec));
});
if (!stale.length) return;
const types = await fetchStructureTypes(true).catch(() => []);
const registry = registryDefaults(types);
for (const section of sections) {
for (const key of FACILITY_KEYS) {
const spec = section[key] as Record<string, unknown> | undefined;
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
}
// 카드 제목 구조물 이름 = 표 카드 이름(서버 `pipe_station_label` 짝 · 배관 = 관종 D관경 · 그 밖 = 종류 이름)
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점
const pipe = stale.find((entry) => {
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
});
if (!pipe) continue;
const facility = pipe.facility ?? "pipe";
section.structure = cardTitle(facility, pipe.options, registry[facility]);
}
attachCulvertSets(sections, buildCulvertSets(stale, registry));
}
/**
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
*
@@ -225,17 +103,6 @@ export async function loadSectionDetail(
return request;
}
/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */
export function replaceSectionDetail(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): void {
cache.set(keyOf(projectId, routeId), detail);
writeState("section-detail", detail, projectId, routeId);
setGeneralStations(detail.longitudinal?.stations);
}
/**
* 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 —
* 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다.
@@ -67,20 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> {
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
// 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함
const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) {
// 빈칸 경고 한 꼴(323-4) — 형상 필수 칸이 빈 관 · 구조물(관경 · 벽 · 기슭막이 높이 · 맹암거 높이 · 월류
// 높이 · 형상 얹기가 측점 계획 자리에 얹음). 세월교 · 박스 흙막이 높이 빈 칸 = 본체 끝 높이(79-7)라 안 셈
for (const item of section.blank ?? [])
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
`⚠ ${item} 빈칸`,
`${item}을 안 적어 그 그림을 안 그림(수치를 지어내지 않음 · 수량도 미확정) — 시설 칸에 적으면 그림`,
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
+5 -10
View File
@@ -1,11 +1,7 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import {
fetchSectionDetail,
regenerateSections,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { replaceSectionDetail } from "./B06_Section_Section_Store";
import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { loadSectionDetail } from "./B06_Section_Section_Store";
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -38,10 +34,9 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) =
showLoadingOverlay();
try {
await regenerateSections(target.projectId, target.routeId, widthM);
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
const fresh = await fetchSectionDetail(target.projectId, target.routeId);
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
replaceSectionDetail(target.projectId, target.routeId, fresh);
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도
// 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4)
const fresh = await loadSectionDetail(target.projectId, target.routeId, { force: true });
deps.applyDetail(fresh);
showToast(L("B06_Profile_Regenerate_Success"), "success");
return true;
+4 -3
View File
@@ -48,6 +48,7 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { catalogRow } from "../common_util/common_util_structure_catalog";
/**
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
@@ -104,8 +105,8 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
writeState("berm", spans, projectId, routeId);
}
/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */
export const BERM_TYPE_ID = "berm";
/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */
export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm";
/**
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
@@ -126,7 +127,7 @@ export function bermSpansFromStructures(
): BermSpan[] {
const spans: BermSpan[] = [];
for (const item of structures) {
if (item.type_id !== BERM_TYPE_ID) continue;
if (!isBermType(item.type_id)) continue;
const anchor = item.chainage_m ?? item.start_m ?? null;
const start = item.start_m ?? anchor;
const end = item.end_m ?? anchor;
@@ -10,7 +10,7 @@ import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
import { isBermType } from "./B06_Section_UI_Page_Persist";
/**
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
@@ -200,7 +200,7 @@ export function wireStructureSelection(
.forEach((box) => box.classList.remove("is-active"));
if (chainageM === null) return;
const berm = panel.getStructures().find((item) => {
if (item.type_id !== BERM_TYPE_ID) return false;
if (!isBermType(item.type_id)) return false;
const start = item.start_m ?? item.chainage_m ?? -1;
const end = item.end_m ?? item.chainage_m ?? -1;
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
+124
View File
@@ -0,0 +1,124 @@
/* =============================================================================
* common_util/common_util_structure_attach.ts
* 형상 얹기 한 벌(323-4) — 측점(횡단) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료.
*
* 구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 서버와 같은 차례로 다시 얹음 — 관 세트 → 벽 →
* 포장 → 맹암거 → 빈칸 경고. 고르기 = 표 형상(322-1). 화면은 초안 ?? 저장분 현황으로 이것 하나를 씀
* (`B06_Section_Section_Drafts.withDraftShapes`) — 저장 전 그림 = 저장 뒤 그림.
* 파이썬 짝 = `B06_Section/B06_Section_Engine_Attach.py` `attach_shapes`(서버 상세 읽기 · 재생성 응답) ·
* 거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
* ========================================================================== */
import {
attachCulvertSets,
buildCulvertSets,
registryDefaults,
type CulvertPipePoint,
} from "./common_util_culvert_sets";
import {
attachWallSpecs,
wallSpecsFrom,
type WallStructureInput,
} from "./common_util_structure_walls";
import {
attachPavementSpecs,
pavementSpecsFrom,
type PavementStructureInput,
} from "./common_util_structure_pavement";
import {
attachUnderdrainSpecs,
underdrainSpecsFrom,
type UnderdrainInput,
} from "./common_util_structure_underdrain";
import { catalogRow, fieldName } from "./common_util_structure_catalog";
import {
blankKeys,
planStations,
SAME_STATION_M,
type PlanPipe,
type PlanStructure,
type PlanType,
} from "./common_util_structure_station";
import { SHAPE_KEYS } from "./common_util_structure_shape_keys";
export { SHAPE_KEYS };
export interface AttachType extends PlanType {
name: string;
options: ReadonlyArray<{ key: string; label?: string; default: unknown }>;
}
/** 빈 필수 칸 → 「종류 이름 칸 이름」(칸 이름 = 그 종류 정의 → 다른 종류 정의 → 부품 상자 → 키). */
function blankLabels(
typeId: string,
options: Record<string, unknown> | null | undefined,
types: ReadonlyArray<AttachType>,
): string[] {
const name = catalogRow(typeId)?.name ?? typeId;
const ordered = [...types.filter((t) => t.type_id === typeId), ...types];
return blankKeys(typeId, options).map((key) => {
const option = ordered.flatMap((t) => t.options).find((o) => o.key === key);
return `${name} ${option?.label ?? fieldName(key) ?? key}`;
});
}
/** 빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(파이썬 `_attach_blanks` 짝). */
function attachBlanks(
sections: Array<Record<string, unknown>>,
pipes: ReadonlyArray<PlanPipe>,
structures: ReadonlyArray<PlanStructure>,
types: ReadonlyArray<AttachType>,
): void {
const owners = new Map<string, [string, Record<string, unknown> | null | undefined]>();
for (const pipe of pipes)
owners.set(pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`, [
pipe.facility ?? "pipe",
pipe.options,
]);
for (const item of structures)
if (item.structure_id) owners.set(item.structure_id, [item.type_id, item.options]);
for (const station of planStations(pipes, structures, types)) {
if (!station.blank.length) continue;
const labels = [
...new Set(
station.owners.flatMap((owner) => {
const hit = owners.get(owner);
return hit ? blankLabels(hit[0], hit[1], types) : [];
}),
),
];
for (const section of sections)
if (Math.abs(Number(section.chainage_m) - station.chainage_m) < SAME_STATION_M)
section.blank = labels;
}
}
/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */
export function attachShapes(
sections: Array<Record<string, unknown>>,
pipes: ReadonlyArray<PlanPipe>,
structures: ReadonlyArray<PlanStructure>,
types: ReadonlyArray<AttachType>,
): void {
for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key];
const registry = registryDefaults(types);
attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry));
const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape);
// 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음
attachWallSpecs(
sections,
wallSpecsFrom(
structures as unknown as WallStructureInput[],
new Map(shaped("wall").map((t) => [t.type_id, t.name] as const)),
),
);
attachPavementSpecs(
sections,
pavementSpecsFrom(
structures as unknown as PavementStructureInput[],
new Set(shaped("pavement").map((t) => t.type_id)),
),
);
attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[]));
attachBlanks(sections, pipes, structures, types);
}
@@ -99,6 +99,11 @@ def type_ids_with(**match: str) -> set[str]:
return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())}
def field_name(key: str) -> str | None:
"""어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · TS `fieldName` 짝)."""
return (load_parts().get("칸 이름") or {}).get(key)
def shape_needs(type_id: str) -> list[str]:
"""형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝."""
row = catalog_row(type_id)
@@ -171,6 +171,10 @@ export function cardTitle(
}
/** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */
/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */
export const fieldName = (key: string): string | undefined =>
(PARTS as unknown as { "칸 이름"?: Record<string, string> })["칸 이름"]?.[key];
export function shapeNeeds(typeId: string): readonly string[] {
const shape = catalogRow(typeId)?.shape ?? "none";
const box = (PARTS as unknown as Parts).parts.형상 as Record<string, { "필수 칸"?: string[] }>;
+2 -1
View File
@@ -1,5 +1,5 @@
{
"comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
"comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).",
"parts": {
"횡단도": {
"anchor": {
@@ -197,6 +197,7 @@
}
}
},
"칸 이름": { "ford_height_m": "월류 높이" },
"처음값": {
"pipe": {
"목록": "both",
@@ -0,0 +1,16 @@
/* =============================================================================
* common_util/common_util_structure_shape_keys.ts
* 측점 그림 자료 열쇠(323-4) — 형상 얹기 한 벌(`common_util_structure_attach.ts`)이 걷고 얹는 것 = 카드 ·
* 상세 창 · B07 · 3D 가 읽는 것 = 3D 지문(`B05_Profile_UI_Corridor_Structures_Hash.ts`). 가져오는 것 없음
* (3D 도움 파일 로더도 그대로 읽게). 파이썬 짝 = `B06_Section_Engine_Attach.SHAPE_KEYS`.
* ========================================================================== */
export const SHAPE_KEYS = [
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
] as const;
+9 -4
View File
@@ -80,6 +80,13 @@ def _empty(value: object) -> bool:
return value is None or value == ""
def blank_keys(type_id: str, options: dict | None) -> list[str]:
"""형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝)."""
from common_util.common_util_structure_catalog import shape_needs
return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))]
def _station_marks(
start: object, anchor: object, end: object, span3: bool
) -> list[tuple[str, float]]:
@@ -99,11 +106,9 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list
· 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상
부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌.
"""
from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs
from common_util.common_util_structure_catalog import card_title, catalog_row
def blank(type_id: str, options: dict | None) -> list[str]:
"""형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝)."""
return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))]
blank = blank_keys
marks: list[dict[str, object]] = []
for pipe in pipes:
+4 -2
View File
@@ -170,8 +170,10 @@ export interface PlannedStation {
const empty = (value: unknown): boolean => value === undefined || value === null || value === "";
/** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */
const blankKeys = (typeId: string, options: Record<string, unknown> | null | undefined): string[] =>
shapeNeeds(typeId).filter((key) => empty(options?.[key]));
export const blankKeys = (
typeId: string,
options: Record<string, unknown> | null | undefined,
): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key]));
/** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션
* 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */
@@ -260,6 +260,52 @@
}
},
"까닭": "★5 관 공식 한 벌 — 표시 이름도 정의 관종을 앎"
},
{
"part": "samples",
"key": "ford_pavement/main",
"column": "cards",
"단계": 4,
"다음": {
"100.00": {
"title": null,
"display": null,
"shape": {
"ford_pavement": true
}
},
"101.00": {
"title": "물넘이포장",
"display": "물넘이포장",
"shape": {
"ford_pavement": true
}
}
},
"까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)"
},
{
"part": "samples",
"key": "ford_pavement/blank",
"column": "cards",
"단계": 4,
"다음": {
"100.00": {
"title": null,
"display": null,
"shape": {
"ford_pavement": true
}
},
"101.00": {
"title": "물넘이포장",
"display": "물넘이포장",
"shape": {
"ford_pavement": true
}
}
},
"까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)"
}
],
"samples": {
+2 -2
View File
@@ -20,9 +20,9 @@ const STUBS = {
},
"./B06_Section_Api_Fetch": {
regenerateSections: async (_p, _r, width) => calls.regenerate.push(width),
fetchSectionDetail: async () => ({ cross_sections: [] }),
},
"./B06_Section_Section_Store": { replaceSectionDetail: () => {} },
// 다시 받기 = 공유 캐시 길(초안 측점 · 그림 자료 · 323-4)
"./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) },
};
const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts");
@@ -56,6 +56,15 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
},
},
"../common_util/common_util_structure_station": station,
// 형상 얹기 한 벌(323-4) 흉내 — 관 자리 측점에 관 그림
"@util/common_util_structure_attach": {
attachShapes: (sections, pipes) => {
for (const pipe of pipes) {
const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005);
if (hit) hit.culvert = true;
}
},
},
"./B06_Section_Api_Fetch": {
previewStructureSections: async (_projectId, chainages) => {
asked.push(chainages);
@@ -86,6 +95,7 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m);
const detail = detailOf();
draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95
await drafts.withDraftStations(detail, "P", 1);
await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례)
const preview = detail.cross_sections.find((s) => s.chainage_m === 95);
out.moved = {
chainages: chainages(detail),
@@ -27,7 +27,7 @@ globalThis.requestAnimationFrame = (fn) => fn();
const STUBS = {
"./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} },
"./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} },
"./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" },
"./B06_Section_UI_Page_Persist": { isBermType: (typeId) => typeId === "berm" },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
@@ -43,9 +43,13 @@ const load = (file) => {
return box.exports;
};
const { withDraftStations, forgetDraftStations } = load(
path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"),
);
const drafts = load(path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"));
const { forgetDraftStations } = drafts;
// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례 · 323-4)
const withDraftStations = async (detail, projectId, routeId) => {
await drafts.withDraftStations(detail, projectId, routeId);
await drafts.withDraftShapes(detail, projectId);
};
const detail = {
cross_sections: [
+11 -1
View File
@@ -229,6 +229,10 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt
B06("B06_Section_Section_Drafts"),
path.join(ROOT, "common_util", "common_util_culvert_sets.ts"),
path.join(ROOT, "common_util", "common_util_structure_station.ts"),
// 그림 자료 = 형상 얹기 한 벌(323-4)
...["attach", "shape_keys", "walls", "pavement", "underdrain"].map((name) =>
path.join(ROOT, "common_util", `common_util_structure_${name}.ts`),
),
],
{
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
@@ -246,7 +250,13 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt
},
},
);
const { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts"));
const drafts = load(B06("B06_Section_Section_Drafts"));
const { forgetDraftStations } = drafts;
// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례)
const withDraftStations = async (detail, projectId, routeId) => {
await drafts.withDraftStations(detail, projectId, routeId);
await drafts.withDraftShapes(detail, projectId);
};
const D800 = { pipe_kind: "파형강관", pipe_diameter_mm: "800" };
const detailWith = (spec) => ({
cross_sections: [
+1
View File
@@ -128,6 +128,7 @@ async function run(setup, detail) {
drafts.forgetDraftStations("P");
route += 1;
await drafts.withDraftStations(detail, "P", route);
await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4)
return summary(detail);
}
+64
View File
@@ -0,0 +1,64 @@
/* PLAN 323-4 형상 얹기 한 벌 헬퍼 — 진짜 `common_util_structure_attach.ts`(attachShapes) · 3D 지문 ·
* 벽 한 번(`B05_Profile_UI_Corridor_Structures_Hash.ts`)을 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON.
* 입력 = argv[2] JSON `{definition, types, cases: {이름: {sections, pipes, structures}}}`. 짝 = `test_323_4_attach_mirror.py`.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
const cache = new Map();
function load(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const need = (spec) => {
const base = path.join(path.dirname(full), spec);
const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
);
if (!hit) throw new Error(`모름: ${spec} (${full})`);
return load(hit);
};
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts"));
const attach = load(path.join(ROOT, "common_util", "common_util_structure_attach.ts"));
const hash = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Hash.ts"));
catalog.useCatalogDefinition({ types: input.types, ...input.definition });
const out = { cases: {} };
for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) {
attach.attachShapes(sections, pipes, structures, input.types);
out.cases[name] = sections;
}
// 3D 벽 한 번 — 옹벽 93 ~ 113(기준 101)이 덮는 측점 넷 중 기준 측점만 · 스윕 = 시작 ~ 끝
const wall = { structure_id: "w1", start_m: 93, anchor_m: 101, end_m: 113 };
const covered = [93, 100, 101, 113].map((at) => ({ chainage_m: at, revetment: { ...wall } }));
out.sweep = covered.map((section) => hash.wallSweepAt(section, covered));
// 3D 지문 — 손 해시가 빠뜨린 칸(벽 기초 · 포장 · 맹암거)도 바뀌면 다름 · 구조물 없는 측점 = 빈 목록
const base = { chainage_m: 0, revetment: { ...wall, foundation: null } };
const fp = (section) => JSON.stringify(hash.structureHashParts(section));
out.fingerprint = {
same: fp(base) === fp({ chainage_m: 0, revetment: { ...wall, foundation: null } }),
foundation: fp(base) !== fp({ ...base, revetment: { ...wall, foundation: "기초유" } }),
pavement: fp(base) !== fp({ ...base, pavement: [{ type_id: "pavement_concrete" }] }),
underdrain: fp(base) !== fp({ ...base, underdrain: { height_m: 0.5 } }),
adjust: fp(base) !== fp({ ...base, design: { revet_adjust: { own: { x: 1 } } } }),
plain: hash.structureHashParts({ chainage_m: 0 }).length === 0,
};
process.stdout.write(JSON.stringify(out));
+5 -5
View File
@@ -158,11 +158,11 @@ def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None:
def test_횡단_벽_초안_빈_목록은_다_지움_그리고_옛_결함_막이() -> None:
store = _body(
"B06_Section/B06_Section_Section_Store.ts", "async function withDraftWalls(", "\n}\n"
)
assert "if (!drafts) return withStructureAreas(detail);" in store
assert "!drafts.length" not in store
# 빈 목록 = 다 지움 — 현황 = 초안 `??` 저장분(빈 목록도 초안) · 형상 얹기가 모든 열쇠를 걷고 다시(323-4)
drafts = _read("B06_Section/B06_Section_Section_Drafts.ts")
assert "structuresDraft ?? savedOf(projectId)" in drafts
assert "const drafts = !!(pipesDraft || structuresDraft);" in drafts
assert "!drafts.length" not in drafts
# 옛 「놓아도 안 보임」(09-09) — 옛 배열 참조가 `[]` 를 알리던 것. 정본 주입은 배열 안을
# 채우고(갈아끼우지 않음) onChange 를 울리지 않아야 함.
inject = _body(
+6 -5
View File
@@ -74,8 +74,9 @@ def test_structure_change_reconciles_and_ford_reattach() -> None:
assert "void reconcileStaleDesigns();" in body
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
assert "Number(section.ford?.overflow_depth_m) > 0" in refresh
store = _read("B06_Section", "B06_Section_Section_Store.ts")
kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0]
assert "if (!pipes) return;" in kinds
assert "delete section.ford_pavement" in kinds
assert 'if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";' in kinds
# 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 ·
# 동작 시험 = test_323_4_attach_mirror)
drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts")
assert "attachShapes(" in drafts
keys = _read("common_util", "common_util_structure_shape_keys.ts")
assert '"ford_pavement",' in keys
+1 -1
View File
@@ -36,7 +36,7 @@ ROOT = HERE.parent.parent
GOLDEN = HERE / "golden_322_structures.json"
FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json"
#: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤
STAGE = 1
STAGE = 4
GRID = [float(at) for at in range(0, 201, 20)]
SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain")
@@ -0,0 +1,178 @@
"""PLAN 323-4 형상 얹기 한 벌 — 파이썬 `attach_shapes` = TS `attachShapes`(거울) · 저장 전 그림 = 저장 뒤 그림.
- 42종 견본(관 지점 · 구조물 · 정의 기본값) + 빈칸 견본 → 측점마다 그림 자료(관 세트 · 벽 · 포장 · 맹암거) ·
빈칸 경고 `blank`(「종류 칸」 · 측점 계획 자리)가 서버 = 화면.
- 옛 열쇠는 걷고 다시(물넘이포장을 옮기면 옛 자리 파임이 빠짐 · 걸친 측점에 새로).
- 3D — C군 벽은 덮는 측점 중 기준 측점에서만 시작 ~ 끝 스윕 · 지문 = 그림 자료 통째(손 해시가 빠뜨린 칸도).
TS 쪽 = `helper_323_4_attach.cjs`(Node).
"""
from __future__ import annotations
import copy
import json
import subprocess
import tempfile
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition
from B06_Section.B06_Section_Engine_Attach import attach_shapes
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_structure_catalog import catalog
from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible
HERE = Path(__file__).parent
ROOT = HERE.parent.parent
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
)
TYPES = with_visible().model_dump()["types"]
def _defaults(type_id: str) -> dict:
"""정의 기본값(빈 값 뺌) — 그림이 서는 견본."""
options = next(t for t in TYPES if t["type_id"] == type_id)["options"]
return {o["key"]: o["default"] for o in options if o["default"] not in (None, "")}
def _samples() -> tuple[list[dict], list[dict]]:
"""42종 하나씩(30 m 간격 · 구간형 = 기준 ± 5) + 빈칸 셋(관경 · 옹벽 높이 · 맹암거 높이)."""
pipes: list[dict] = []
structures: list[dict] = []
rows = sorted(catalog().values(), key=lambda row: row["typeId"])
for index, row in enumerate(rows):
at = 15.0 + 30.0 * index
options = _defaults(row["typeId"])
if row["owner"] == "pipe_points":
pipes.append({"chainage_m": at, "facility": row["typeId"], "options": options})
continue
span = {"start_m": at - 5, "end_m": at + 5} if row["placement"] == "interval" else {}
structures.append(
{
"structure_id": f"s{index}",
"type_id": row["typeId"],
"placement": row["placement"],
"chainage_m": at,
"options": options,
**span,
}
)
end = 15.0 + 30.0 * len(rows)
pipes.append({"chainage_m": end, "facility": "pipe", "options": {"pipe_diameter_mm": ""}})
structures.append(
{
"structure_id": "blank-wall",
"type_id": "retaining_wall",
"placement": "interval",
"start_m": end + 25,
"chainage_m": end + 30,
"end_m": end + 35,
"options": {},
}
)
structures.append(
{
"structure_id": "blank-drain",
"type_id": "cross_underdrain",
"placement": "point",
"chainage_m": end + 60,
"options": {},
}
)
return pipes, structures
def _sections(until: float) -> list[dict]:
"""5 m 마다 측점(모든 기준 · 시작 · 끝 자리) — 옛 열쇠를 미리 얹어 걷힘도 봄."""
out = []
for step in range(int(until // 5) + 2):
section = {"chainage_m": float(step * 5)}
if step == 1:
section["ford_pavement"] = {"type": "ford_pavement", "chainage_m": 5.0} # 옛 자리
out.append(section)
return out
PIPES, STRUCTURES = _samples()
UNTIL = 15.0 + 30.0 * len(catalog()) + 60
CASES = {
"all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES},
"empty": {"sections": _sections(30), "pipes": [], "structures": []},
}
@pytest.fixture(scope="module")
def ts() -> dict:
payload = {"definition": load_definition(), "types": TYPES, "cases": CASES}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "input.json"
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
out = subprocess.run(
["node", str(HERE / "helper_323_4_attach.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
)
return json.loads(out.stdout)
def _py(case: dict) -> list[dict]:
sections = copy.deepcopy(case["sections"])
pipes = parse_pipe_points(case["pipes"])
structures = [StructureInstance.model_validate(s) for s in case["structures"]]
attach_shapes(sections, pipes, structures)
return json.loads(json.dumps(sections))
def test_mirror_python_equals_ts(ts: dict) -> None:
for name, case in CASES.items():
py, js = _py(case), ts["cases"][name]
assert len(py) == len(js), name
for left, right in zip(py, js):
assert left == right, (name, left["chainage_m"])
def test_every_shape_lands(ts: dict) -> None:
keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"}
assert {
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
} <= keys
old = ts["cases"]["all"][1] # 옛 물넘이 자리(5 m) — 걷힘
assert "ford_pavement" not in old
assert all(len(section) == 1 for section in ts["cases"]["empty"])
def test_blank_chips(ts: dict) -> None:
end = 15.0 + 30.0 * len(catalog())
by_at = {s["chainage_m"]: s.get("blank") for s in ts["cases"]["all"]}
assert by_at[end] == ["배관 관경"]
assert by_at[end + 25] == by_at[end + 30] == by_at[end + 35] == ["옹벽 높이"]
assert by_at[end + 60] == ["맹암거 암거 높이"]
assert by_at[end + 5] is None # 계획 자리만
def test_wall_sweeps_once(ts: dict) -> None:
assert ts["sweep"] == [None, None, {"beforeM": 8, "afterM": 12}, None]
def test_fingerprint_covers_drawing(ts: dict) -> None:
assert ts["fingerprint"] == {
"same": True,
"foundation": True,
"pavement": True,
"underdrain": True,
"adjust": True,
"plain": True,
}
@@ -57,6 +57,7 @@ def test_물넘이_월류_높이가_비면_파임_없음() -> None:
def test_카드_머리가_목록과_까닭을_보인다() -> None:
assert "기본값(미확정)" in CHROME
assert "월류 높이 없음 — 파임 안 그림" in CHROME
# 월류 높이 · 기슭막이 높이 등 필수 칸 빈칸 = 빈칸 경고 한 꼴(323-4 · 형상 얹기가 얹은 `blank`)
assert "section.blank" in CHROME and "빈칸`" in CHROME
# 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길.
assert CONTROLS.count("dropDefaulted(spec,") == 4