Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-06 23:23:00 +09:00
29 changed files with 4558 additions and 212 deletions
+36 -28
View File
@@ -2,17 +2,20 @@
* design_structure_box.ts
* 횡단배수 › 박스암거 명세 — 사용자 구조물 표 02 고친 판(`docs/raw/구조물정의_표/02_횡단배수_박스암거.md`) 그대로.
*
* 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 컨테이너: 본체 · 유입 구조 · 유출 구조(필수) · 흙막이(선택 · 여러 개) · 도수로/돌붙임(선택 · 하나 · 65-2).
* 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리):
* · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`)
* · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만
* · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽)
* — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝)
* — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝) · 흙막이 = 배관과 같은 양식(`{side}_revet_*` · 65-2) ·
* 「없음」 = 목록 램프 · 잇기 줄 없음(65-3)
* 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값).
* import 없음(꼴만) — 시험이 이 파일만 옮겨 돌림.
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
* ========================================================================== */
import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec";
import type { Instance, SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec";
import { CHUTE_ADD, guardItems, revetKeys, SOIL_ADD } from "./design_structure_guard";
export const BOX_CUSTOM = "사용자 입력";
/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */
@@ -23,16 +26,31 @@ const BOX_PRESETS: Record<string, readonly [number, number]> = {
"3.0×3.0": [3, 3],
};
/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름. */
const structureItem = (key: string): SpecItem => ({
id: "structure",
label: "구조",
kind: "select",
key,
choices: [
{ value: "없음", label: "없음" },
{ value: "날개벽", label: "날개벽" },
{ value: "흙막이", label: "흙막이" },
/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름 · 흙막이면 흙막이 칸(65-2). */
const guardContainer = (
id: "inlet" | "outlet",
title: string,
link: string,
key: string,
): SpecContainer => ({
id,
title,
required: true,
none: "없음",
link,
items: [
{
id: "structure",
label: "구조",
kind: "select",
key,
choices: [
{ value: "없음", label: "없음" },
{ value: "날개벽", label: "날개벽" },
{ value: "흙막이", label: "흙막이" },
],
},
...guardItems({ 흙막이: revetKeys(id) }, undefined, true),
],
});
@@ -70,20 +88,10 @@ export const BOX_SPEC: StructureSpec = {
customSide("height", "세로 (m)", "body_height_m"),
],
},
{
id: "inlet",
title: "유입 구조",
required: true,
link: "gboxin",
items: [structureItem("wing_in")],
},
{
id: "outlet",
title: "유출 구조",
required: true,
link: "gboxout",
items: [structureItem("wing_out")],
},
guardContainer("inlet", "유입 구조", "gboxin", "wing_in"),
guardContainer("outlet", "유출 구조", "gboxout", "wing_out"),
SOIL_ADD,
CHUTE_ADD,
],
// 표 02 에 없는 옛 날개벽 치수 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: ["in", "out"].flatMap((side) =>
+13 -5
View File
@@ -1,15 +1,18 @@
/* =============================================================================
* design_structure_ford.ts
* 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로.
* 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) + 65장.
*
* 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조
* 컨테이너: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수) · 흙막이(선택 · 여러 개) · 도수로/돌붙임(선택 · 하나).
* 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 유입 · 유출 구조
* (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음.
* 흙막이 = 배관과 같은 양식(`design_structure_guard` · 키 `{side}_revet_*` · 65-2) · 구조 「없음」 = 목록 램프 · 잇기 줄 없음(65-3).
* 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
* ========================================================================== */
import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec";
import { CHUTE_ADD, guardItems, revetKeys, SOIL_ADD } from "./design_structure_guard";
/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */
const guard = (side: "in" | "out"): SpecChoice[] => [
@@ -22,6 +25,7 @@ const guardContainer = (id: "inlet" | "outlet", title: string, link: string): Sp
id,
title,
required: true,
none: "없음",
link,
items: [
{
@@ -31,6 +35,7 @@ const guardContainer = (id: "inlet" | "outlet", title: string, link: string): Sp
key: `${id}_type`,
choices: guard(id === "inlet" ? "in" : "out"),
},
...guardItems({ 흙막이: revetKeys(id) }, undefined, true),
],
});
@@ -51,13 +56,16 @@ export const FORD_SPEC: StructureSpec = {
asNumber: true,
choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })),
},
{ id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 }, // 관경 바로 뒤(65-4)
// 월류 폭 · 높이 = 물넘이 포장과 같은 키 · 같은 계산(65-5 · B06 횡단이 읽음)
{ id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 },
{ id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 },
{ id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 },
],
},
guardContainer("inlet", "유입 구조", "gfordin"),
guardContainer("outlet", "유출 구조", "gfordout"),
SOIL_ADD,
CHUTE_ADD,
],
// 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: [
@@ -9,6 +9,8 @@
* 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음.
* 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함.
* 설계유량이 없으면 null = 손 입력.
* 짝: 자동 배치 새 세월교 빈 높이 = 서버 `B04_PreProcess_Router_Basins.ford_height_m`(같은 풀이 · 65-4) ·
* 거울 시험 test_65_ford_defaults.
* ========================================================================== */
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
@@ -2,8 +2,9 @@
* design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
*
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure`.
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
@@ -20,6 +21,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
link: "gfordpaveslope",
items: [
{ id: "angle", label: "경사각도 (°)", kind: "number", key: "ford_slope_deg", step: 0.1 },
{ id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 },
{ id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 },
],
},
{
@@ -43,5 +46,5 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
},
],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
retired: ["ford_slope_pct", "thickness_cm", "length_m"],
};
+5 -2
View File
@@ -16,6 +16,7 @@ import {
choiceLabel,
choicesOf,
containerTitle,
isNone,
itemLabel,
linkOf,
readState,
@@ -190,11 +191,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
for (const r of ["length", "before", "after"] as SplitRole[])
values[role(r).id] = solved[r] ?? "";
}
// 위 항목이 바뀌면 갈래(선택지 · 저장 키)가 달라짐 — 선택지 밖 값은 비움
// 위 항목이 바뀌면 갈래(선택지 · 저장 키)가 달라짐 — 선택지 밖 값은 비움 · 새로 선 빈 칸은 기본값(65-2)
for (const child of c.items.filter((i) => i.by?.item === item.id)) {
const allowed = choicesOf(child, values);
if (child.kind === "select" && !allowed.some((ch) => ch.value === values[child.id]))
values[child.id] = "";
if (child.default && !values[child.id] && branchOf(child, values))
values[child.id] = child.default;
}
paint();
hooks.onChange?.(c, item);
@@ -252,7 +255,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
}
box.body.append(slot);
}
if (links) {
if (links && !isNone(c, values)) {
const key = linkOf(c, index + 1);
const row = buildSummaryRow();
row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합";
+114
View File
@@ -0,0 +1,114 @@
/* =============================================================================
* design_structure_guard.ts
* 흙막이 공통 양식 — 배관(표 01) · 박스암거(표 02) · 세월교(표 04)가 같은 칸을 씀(PLAN 65-2).
*
* · 흙막이 = 형태 6 · 길이 · 높이 · 기준측점 전 · 후(길이 = 전 + 후 맞물림)
* · 필수 보호공(유입 · 유출)은 구조 갈래마다 저장 키(`{side}_revet_*` 옛 키) · 선택 흙막이 · 도수로/돌붙임은
* 인스턴스 키(`soil_add_{n}_*` · `chute_add_{n}_*`)
* import = 꼴만 — 시험이 이 폴더 파일만 옮겨 돌림.
* ========================================================================== */
import type { SpecChoice, SpecContainer, SpecItem } from "./design_structure_spec";
export const SOIL_FORMS: SpecChoice[] = [
{ value: "돌쌓기(찰)", label: "돌 쌓기(찰)" },
{ value: "돌쌓기(메)", label: "돌 쌓기(메)" },
{ value: "콘크리트", label: "콘크리트" },
{ value: "돌망태", label: "돌망태" },
{ value: "통나무·목재틀", label: "통나무/목재틀" },
{ value: "바자", label: "바자" },
];
/** 흙막이 기본값(54-36 · 사면안정 흙막이 정의 기본값과 같음). */
const SOIL_DEFAULTS: Record<string, string> = {
form: "돌쌓기(찰)",
length: "10",
height: "1.5",
before: "5",
after: "5",
};
/** 형태 · 길이 · 높이 · 전 · 후 — 필수 보호공은 구조 갈래마다 옛 키(`keys`) · 선택 흙막이는 인스턴스 키.
* `forms` = 갈래별 형태 선택지(배관 집수정 등) · `defaults` = 새 상자 · 흙막이를 고를 때 칸에 실제 값. */
export function guardItems(
keys?: Record<string, Record<string, string>>,
forms?: Record<string, SpecChoice[]>,
defaults = !keys,
): SpecItem[] {
const by = (field: string, choices?: Record<string, SpecChoice[]>) =>
keys
? {
item: "structure",
map: Object.fromEntries(
// 키 없는 갈래 = 그 구조에 없는 칸(집수정 길이 · 전 · 후) — 가림
Object.entries(keys)
.filter(([, k]) => k[field])
.map(([structure, k]) => [
structure,
{ key: k[field], choices: choices?.[structure] },
]),
),
}
: undefined;
const items: SpecItem[] = [
{ id: "form", label: "형태", kind: "select", choices: SOIL_FORMS, by: by("form", forms) },
{ id: "length", label: "길이 (m)", kind: "number", split: "length", step: 1, by: by("length") },
{ id: "height", label: "높이 (m)", kind: "number", step: 0.1, by: by("height") },
{
id: "before",
label: "기준측점 전 (m)",
kind: "number",
split: "before",
step: 0.5,
by: by("before"),
},
{
id: "after",
label: "기준측점 후 (m)",
kind: "number",
split: "after",
step: 0.5,
by: by("after"),
},
];
return defaults ? items.map((item) => ({ ...item, default: SOIL_DEFAULTS[item.id] })) : items;
}
export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => ({
form: `${side}_revet_form`,
length: `${side}_revet_length_m`,
height: `${side}_revet_height_m`,
before: `${side}_revet_before_m`,
after: `${side}_revet_after_m`,
});
/** 추가 흙막이(선택 · 여러 개) — 사면안정 흙막이(표 09 · 54-26)와 같은 칸 · 구조 고르기 없음(54-30). */
export const SOIL_ADD: SpecContainer = {
id: "soil_add",
title: "흙막이",
required: false,
prefix: "soil_add",
link: "gsoiladd",
items: guardItems(),
};
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
export const CHUTE_ADD: SpecContainer = {
id: "chute_add",
title: "도수로/돌붙임",
required: false,
prefix: "chute_add",
link: "gchuteadd",
max: 1,
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
],
};
+8 -102
View File
@@ -13,6 +13,7 @@
* ========================================================================== */
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
import { CHUTE_ADD, guardItems, revetKeys, SOIL_ADD, SOIL_FORMS } from "./design_structure_guard";
import { BOX_SPEC } from "./design_structure_box";
import { FORD_SPEC } from "./design_structure_ford";
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
@@ -20,83 +21,14 @@ import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정";
const SOIL_FORMS: SpecChoice[] = [
{ value: "돌쌓기(찰)", label: "돌 쌓기(찰)" },
{ value: "돌쌓기(메)", label: "돌 쌓기(메)" },
{ value: "콘크리트", label: "콘크리트" },
{ value: "돌망태", label: "돌망태" },
{ value: "통나무·목재틀", label: "통나무/목재틀" },
{ value: "바자", label: "바자" },
];
const BASIN_FORMS: SpecChoice[] = [
{ value: "I", label: "I형" },
{ value: "L", label: "L형" },
{ value: "U", label: "ㄷ형" },
];
/** 추가 흙막이 기본값(54-36 · 사면안정 흙막이 정의 기본값과 같음) — 필수 보호공 기본값은 정의 파일. */
const SOIL_ADD_DEFAULTS: Record<string, string> = {
form: "돌쌓기(찰)",
length: "10",
height: "1.5",
before: "5",
after: "5",
};
/** 형태 · 길이 · 높이 · 전 · 후 — 필수 보호공은 구조 갈래마다 옛 키 · 선택 흙막이는 인스턴스 키. */
function guardItems(keys?: Record<string, Record<string, string>>): SpecItem[] {
const by = (field: string, choices?: Record<string, SpecChoice[]>) =>
keys
? {
item: "structure",
map: Object.fromEntries(
// 키 없는 갈래 = 그 구조에 없는 칸(집수정 길이 · 전 · 후) — 가림
Object.entries(keys)
.filter(([, k]) => k[field])
.map(([structure, k]) => [
structure,
{ key: k[field], choices: choices?.[structure] },
]),
),
}
: undefined;
const items: SpecItem[] = [
{
id: "form",
label: "형태",
kind: "select",
choices: SOIL_FORMS,
by: by("form", { [BASIN]: BASIN_FORMS, [SOIL]: SOIL_FORMS }),
},
{ id: "length", label: "길이 (m)", kind: "number", split: "length", step: 1, by: by("length") },
{ id: "height", label: "높이 (m)", kind: "number", step: 0.1, by: by("height") },
{
id: "before",
label: "기준측점 전 (m)",
kind: "number",
split: "before",
step: 0.5,
by: by("before"),
},
{
id: "after",
label: "기준측점 후 (m)",
kind: "number",
split: "after",
step: 0.5,
by: by("after"),
},
];
return keys ? items : items.map((item) => ({ ...item, default: SOIL_ADD_DEFAULTS[item.id] }));
}
const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => ({
form: `${side}_revet_form`,
length: `${side}_revet_length_m`,
height: `${side}_revet_height_m`,
before: `${side}_revet_before_m`,
after: `${side}_revet_after_m`,
});
/** 보호공 형태 · 길이 · 높이 · 전 · 후 — 구조 갈래마다 옛 키(흙막이 양식 = `design_structure_guard`). */
const protectItems = (keys: Record<string, Record<string, string>>): SpecItem[] =>
guardItems(keys, { [BASIN]: BASIN_FORMS, [SOIL]: SOIL_FORMS });
export const PIPE_SPEC: StructureSpec = {
typeId: "pipe",
@@ -134,7 +66,7 @@ export const PIPE_SPEC: StructureSpec = {
{ value: SOIL, label: "흙막이", also: { inlet_structure: "revet" } },
],
},
...guardItems({
...protectItems({
[BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" },
[SOIL]: revetKeys("inlet"),
}),
@@ -153,37 +85,11 @@ export const PIPE_SPEC: StructureSpec = {
key: "outlet_type",
choices: [{ value: SOIL, label: "흙막이" }],
},
...guardItems({ [SOIL]: revetKeys("outlet") }),
],
},
{
id: "soil_add",
title: "흙막이",
required: false,
prefix: "soil_add",
link: "gsoiladd",
// 사면안정 흙막이(표 09 · 54-26)와 같은 칸 — 형태 6 · 길이 · 높이 · 전 · 후 · 구조 고르기 없음(54-30)
items: guardItems(),
},
{
id: "chute_add",
title: "도수로/돌붙임",
required: false,
prefix: "chute_add",
link: "gchuteadd",
max: 1, // 하나만(54-48) — 옛 둘 이상은 보이되 [추가]만 숨김
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
},
...protectItems({ [SOIL]: revetKeys("outlet") }),
],
},
SOIL_ADD, // 개수 = 유출 「추가 기슭막이(단)」(54-1)
CHUTE_ADD,
],
// 표 01 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
retired: ["pipe_kind", "wing_wall_type", "inlet_basin_form", "inlet_basin_material"],
@@ -59,6 +59,8 @@ export interface SpecContainer {
max?: number;
/** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */
link: string;
/** 첫 항목이 이 값이면 그 컨테이너는 없는 셈 — 목록 램프 · 잇기 줄 뺌(65-3 · 세월교 · 박스 유입 · 유출 「없음」). */
none?: string;
items: SpecItem[];
}
@@ -103,6 +105,10 @@ export function choicesOf(item: SpecItem, values: Instance): SpecChoice[] {
return branchOf(item, values)?.choices ?? item.choices ?? [];
}
/** 컨테이너가 「없음」으로 골라졌는지(`none`). */
export const isNone = (c: SpecContainer, values: Instance): boolean =>
!!c.none && values[c.items[0]?.id] === c.none;
/** 인스턴스 잇기 키 — 필수 = 그룹 id · 선택 = `{link}{n}`. */
export const linkOf = (c: SpecContainer, n: number): string =>
c.required ? c.link : `${c.link}${n}`;
+42 -3
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import asyncio
import logging
import math
from typing import Any
from uuid import UUID
@@ -41,7 +42,12 @@ from common_util.common_util_drainage_pipes import (
)
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_route_geometry import StructureCandidate
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
from config.config_system import (
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
FORD_MANNING_N,
FORD_SLOPE,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Drainage Basins"])
@@ -78,15 +84,20 @@ def _build(
carried = carry_facility_attributes(rebuilt, points or [])
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
_apply_recommendations(detail, carried)
_apply_new_defaults([point for point, new in zip(carried, fresh) if new])
flows = {round(basin.chainage_m, 2): basin.design_flow_m3s for basin in detail.basins}
_apply_new_defaults([point for point, new in zip(carried, fresh) if new], flows)
return detail, carried
def _apply_new_defaults(points: list[PipePoint]) -> None:
def _apply_new_defaults(
points: list[PipePoint], flows: dict[float, float | None] | None = None
) -> None:
"""새로 놓인 관에만 등록부 기본값을 **빈 키에** 채움(PLAN 46-7 · 추천 뒤 — 추천 관경이 이김).
시설이 생길 때 기본값을 칸에 실제 값으로 넣어 두고 사용자가 고침 — 이미 있던 관의 빈 칸은 그대로
(옛 프로젝트 빈칸 = 미확정 유지 · 사용자 결정 2026-10-05).
세월교 빈 월류 높이 = 담당 유역 설계유량(`flows` · 측점 → ㎥/s)을 월류 폭으로 넘기는
필요 수심(65-4).
"""
types = structure_type_map()
for point in points:
@@ -100,9 +111,37 @@ def _apply_new_defaults(points: list[PipePoint]) -> None:
# 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`)
if options.get("inlet_type") == "집수정":
options.setdefault("inlet_structure", "L")
if point.facility == "ford_bridge" and "ford_height_m" not in options:
flow = (flows or {}).get(round(point.chainage_m, 2))
height = ford_height_m(flow, float(options.get("ford_width_m") or 0))
if height is not None:
options["ford_height_m"] = height
point.options = options or None
def ford_height_m(flow_m3s: float | None, width_m: float) -> float | None:
"""월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림 — 유량 · 폭이 없으면 None.
짝: `A00_Common/design/design_structure_ford_calc.fordHeightM`(+ `fordSection` 같은 풀이 ·
Manning n · S = `FORD_MANNING_N` · `FORD_SLOPE`) · 거울 시험 test_65_ford_defaults.
"""
if not flow_m3s or flow_m3s <= 0 or not math.isfinite(width_m) or width_m <= 0:
return None
root = math.sqrt(FORD_SLOPE)
def velocity(depth: float) -> float:
return (1 / FORD_MANNING_N) * ((width_m * depth) / (width_m + 2 * depth)) ** (2 / 3) * root
depth = ((flow_m3s * FORD_MANNING_N) / (width_m * root)) ** (3 / 5)
for _ in range(20):
nxt = flow_m3s / (width_m * velocity(depth))
if abs(nxt - depth) < 1e-6:
depth = nxt
break
depth = nxt
return math.ceil(depth * 100 - 1e-9) / 100
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
"""확정 좌표로 되돌아온 관에 원래의 생성 사유를 다시 붙인다."""
by_chainage = {round(point.chainage_m, 2): point.source for point in points}
+60 -22
View File
@@ -46,9 +46,14 @@ import {
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import {
createSpecForm,
type SpecForm,
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import type { SpecContainer, SpecItem } from "../A00_Common/design/design_structure_spec";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
@@ -219,6 +224,8 @@ export interface FacilityOptionsForm {
/** 옵션 정의 기본값 — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
options?: Array<{ key: string; default: string | number | null }>;
} | null,
/** 새 시설(등록부 기본값으로 염) — 세월교 · 물넘이 빈 월류 높이를 유량이 오면 필요 수심으로(65-4). */
fresh?: boolean,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
bindLinks: (links: SpecLinks) => void;
@@ -372,20 +379,40 @@ export function createFacilityOptionsForm(
: "auto";
};
// ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ──
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`)
// ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4 · 65장) — 세월교 · 유입 구조 · 유출 구조 · 흙막이 · 도수로/돌붙임 ──
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) —
// 물넘이 포장도 같은 키 · 같은 계산(65-5) · 새 시설은 유량이 오면 빈 높이를 필요 수심으로(65-4)
let fordFlow: number | null = null;
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
onChange: (c, item) => {
const need = c.id === "ford" ? fordNeed() : null;
const height = fordForm.state().ford?.[0]?.height ?? "";
const low = height !== "" && !(Number(height) >= (need ?? 0));
if (need !== null && (item.id === "width" || (item.id === "height" && low)))
fordForm.setValue("ford", "height", need.toFixed(2));
emit();
},
let heightPending = false;
const overflow = (form: () => SpecForm, box: string) => {
const values = () => form().state()[box]?.[0] ?? {};
const need = (): number | null => fordHeightM(fordFlow, Number(values().width ?? ""));
return {
onChange: (c: SpecContainer, item: SpecItem) => {
if (c.id === box && item.id === "height") heightPending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height !== "" && !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 새 시설 빈 높이 = 필요 수심(사용자가 넣은 것처럼 · 알림 없음 — [추가] · [수정]이 읽음). */
fill: () => {
const want = heightPending && !values().height ? need() : null;
if (want === null) return;
form().setValue(box, "height", want.toFixed(2));
heightPending = false;
},
};
};
const fordFlowBox = overflow(() => fordForm, "ford");
const fordForm: SpecForm = createSpecForm(specLinks, {
onChange: fordFlowBox.onChange,
fields: guardChoiceFields(
() => chainage,
() => fordForm.refresh(),
),
});
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
@@ -440,8 +467,14 @@ export function createFacilityOptionsForm(
return merged;
};
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · 65장) — 본체 · 유입 구조 · 유출 구조 · 흙막이 · 도수로/돌붙임 ──
const boxForm: SpecForm = createSpecForm(specLinks, {
onChange: () => emit(),
fields: guardChoiceFields(
() => chainage,
() => boxForm.refresh(),
),
});
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
@@ -450,9 +483,10 @@ export function createFacilityOptionsForm(
const hiddenOptions = createHiddenOptions(root);
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도 · 도수로/돌붙임 ·
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도(+ 월류 폭 · 높이 · 65-5) · 도수로/돌붙임 ·
// 옛 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
const paveFlowBox = overflow(() => paveForm, "slope");
const paveForm: SpecForm = createSpecForm(specLinks, { onChange: paveFlowBox.onChange });
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
@@ -567,8 +601,9 @@ export function createFacilityOptionsForm(
setHiddenOptions(facility, registryKeys) {
hiddenOptions.set(facility, registryKeys);
},
setFacility(facility, options = {}, designFlow = null, definition = null) {
setFacility(facility, options = {}, designFlow = null, definition = null, fresh = false) {
current = facility;
heightPending = fresh;
hiddenOptions.remember(options);
fordFlow = designFlow ?? null;
if (facility === null) {
@@ -609,6 +644,8 @@ export function createFacilityOptionsForm(
);
if (facility === "ford_pavement")
paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups, defaults);
if (facility === "ford_bridge") fordFlowBox.fill();
if (facility === "ford_pavement") paveFlowBox.fill();
wingInFields.write(options);
wingOutFields.write(options);
revetSide.value = text("side") || "양쪽";
@@ -622,7 +659,8 @@ export function createFacilityOptionsForm(
},
setDesignFlow(designFlow) {
fordFlow = designFlow ?? null;
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
if (current === "ford_bridge") fordFlowBox.fill();
if (current === "ford_pavement") paveFlowBox.fill();
syncVisibility();
},
readOptions() {
@@ -33,10 +33,19 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
...revetKeys("outlet"),
],
// BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값)
box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"],
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는
// 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
ford_pavement: ["ford_slope_deg"],
// 흙막이 = 배관과 같은 키(65-2) · 추가 흙막이 · 도수로/돌붙임은 `FORM_OPTION_PREFIXES`
box_culvert: [
"body_width_m",
"body_height_m",
"body_custom",
"wing_in",
"wing_out",
...revetKeys("inlet"),
...revetKeys("outlet"),
],
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 ·
// 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
ford_pavement: ["ford_slope_deg", "ford_width_m", "ford_height_m"],
// 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임
ford_bridge: [
"pipe_diameter_mm",
@@ -47,6 +56,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"outlet_type",
"wing_in",
"wing_out",
...revetKeys("inlet"),
...revetKeys("outlet"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
@@ -54,6 +65,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
/** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */
export const FORM_OPTION_PREFIXES: Readonly<Record<string, readonly string[]>> = {
pipe: ["soil_add_", "chute_add_"],
box_culvert: ["soil_add_", "chute_add_"],
ford_bridge: ["soil_add_", "chute_add_"],
ford_pavement: ["ford_chute_"],
};
@@ -53,3 +53,21 @@ export function revetChoiceRow(
}
return { paint };
}
/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄.
* 명세 폼 `fields` 자리에 그대로 씀. */
export function guardChoiceFields(
chainageOf: () => number | null,
written: () => void,
): (c: { id: string }, values: Record<string, string>) => HTMLElement[] {
const sides = {
inlet: revetChoiceRow(chainageOf, written),
outlet: revetChoiceRow(chainageOf, written),
};
return (c, values) =>
c.id === "soil_add"
? [revetChoiceRow(chainageOf, written).paint(false)]
: c.id === "inlet" || c.id === "outlet"
? [sides[c.id].paint(values.structure !== "흙막이")]
: [];
}
+19 -4
View File
@@ -24,7 +24,13 @@ import {
} from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
import { containerTitle, linkOf, readState } from "../A00_Common/design/design_structure_spec";
import {
containerTitle,
isNone,
linkOf,
readState,
} from "../A00_Common/design/design_structure_spec";
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import { asRoad } from "./B05_Profile_UI_Structures_Define";
export interface StructureListParams {
@@ -100,11 +106,20 @@ export function linkLamps(
// 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음)
const { soil_add: _old, ...pipeNames } = type?.spec_names ?? {};
const names = source.type_id === "pipe" ? pipeNames : (type?.spec_names ?? {});
const state = readState(spec, source.options ?? {});
// 세월교 옛 날개벽 설치 칸 → 구조(시설 폼과 같은 길 · 「없음」이면 램프 없음 · 65-3)
const options = source.options ?? {};
const state = readState(
spec,
source.type_id === "ford_bridge" ? withLegacyGuards(options) : options,
);
return spec.containers.flatMap((c): LinkLamp[] => {
const title = containerTitle(c, names, groupNames[c.link]);
if (c.required) return [{ title, item: links[c.link] ?? null, required: true }];
const n = (c.id === "soil_add" ? pipeExtraCount(chainageM) : null) ?? state[c.id].length;
if (c.required)
return isNone(c, state[c.id][0])
? []
: [{ title, item: links[c.link] ?? null, required: true }];
const pipeSoil = source.type_id === "pipe" && c.id === "soil_add";
const n = (pipeSoil ? pipeExtraCount(chainageM) : null) ?? state[c.id].length;
return Array.from({ length: n }, (_, i) => ({
title: `${title} ${i + 1}`,
item: links[linkOf(c, i + 1)] ?? null,
@@ -250,7 +250,9 @@ export function createStructuresSection(
): void {
const kind = facilityFormKind(currentType());
facilityOptions.setChainage(selectedPipeChainage ?? tempPipeChainage); // 관 길이(자동 · 52-1)
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow, currentType());
// 값 없이 열면 새 시설 — 빈 월류 높이를 유량이 오면 필요 수심으로(65-4)
const fresh = options === undefined;
facilityOptions.setFacility(kind, options ?? newDefaults(), designFlow, currentType(), fresh);
if (kind)
paintStale(staleBox, currentType(), options, kind, groupBoxes.shapeKeys(currentType()));
// 「구조물 옵션 보이기」에서 끈 옵션은 시설 폼에서도 가림(값은 옛 값 그대로 저장)
@@ -106,7 +106,7 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void {
ctx.setSummary(readSummaryItem(back?.summary_item));
ctx.groupBoxes.set(readGroupItems(back?.group_items));
ctx.groupBoxes.setShapes(back?.options ?? {});
ctx.syncFacilityForm(back?.options);
ctx.syncFacilityForm(back?.options, pipeItem?.design_flow_m3s ?? null); // 유량 = 월류 높이(65-4)
ctx.liveCommit();
// 반영이 막혀(측점 등) 기록이 그대로면 옛 기록으로 되돌림
if (record && draft && ctx.structures[index] === draft) ctx.structures[index] = record;
+6 -3
View File
@@ -65,8 +65,10 @@ const PIPE_INLET_ROWS = [
].join(", ");
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로. */
function rowsFor(slot: HTMLElement): string {
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 안 받음
* (높이 · 측벽 좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 강조만 · 65-1). */
function rowsFor(slot: HTMLElement): string | null {
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
}
@@ -133,7 +135,8 @@ function clearSideSlots(): void {
}
/** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */
function moveRows(panelRoot: HTMLElement, selector: string, target: HTMLElement): void {
function moveRows(panelRoot: HTMLElement, selector: string | null, target: HTMLElement): void {
if (!selector) return;
panelRoot.querySelectorAll<HTMLElement>(selector).forEach((row) => {
if (row.classList.contains("is-hidden")) return;
// 제자리에 표식을 남기고 옮긴다 — 선택이 바뀌면 이 자리로 되돌린다.
+14 -6
View File
@@ -4,7 +4,7 @@
집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값.
열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`)
컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값.
짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) —
짝: `A00_Common/design/design_structure_guard.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) —
시험이 대조.
기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」.
배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11).
@@ -18,11 +18,14 @@ from typing import Any
from common_util.common_util_summary_item import GROUP_JOIN
#: 종류 → 선택 컨테이너 (머리, 잇기, 제목)
_GUARD = (
("soil_add", "gsoiladd", "흙막이"),
("chute_add", "gchuteadd", "도수로/돌붙임"),
)
SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = {
"pipe": (
("soil_add", "gsoiladd", "흙막이"),
("chute_add", "gchuteadd", "도수로/돌붙임"),
),
"pipe": _GUARD,
"box_culvert": _GUARD, # 흙막이 양식 공통(65-2)
"ford_bridge": _GUARD,
"ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),),
"etc": (("etc_detail", "getcdetail", "상세 내용"),),
}
@@ -75,7 +78,12 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str
if hit:
found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value
for n in sorted(found):
if prefix == "soil_add" and tiers is not None and n > tiers:
if (
prefix == "soil_add"
and item["type_id"] == "pipe"
and tiers is not None
and n > tiers
):
continue
gid = f"{link}{n}"
rows.append(
+92 -6
View File
@@ -1,5 +1,5 @@
{
"판": 17,
"판": 18,
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"구조물군": {
@@ -486,6 +486,15 @@
"quantity"
],
"options": [
{
"key": "ford_slope_deg",
"label": "경사각도",
"input": "number",
"unit": "°",
"default": 3,
"required": false,
"default_basis": "물넘이 포장 경사각 기본 3° — 사용자 확정값(PLAN 65-5)"
},
{
"key": "ford_width_m",
"label": "월류 폭",
@@ -575,8 +584,9 @@
"label": "배관 수량",
"input": "number",
"unit": "련",
"default": null,
"required": false
"default": 2,
"required": false,
"default_basis": "세월교 배관 수량 기본 2련 — 사용자 확정값(PLAN 65-4)"
},
{
"key": "ford_width_m",
@@ -3381,10 +3391,48 @@
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"inlet.form": "형태",
"inlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"inlet.form=돌쌓기(메)": "돌 쌓기(메)",
"inlet.form=콘크리트": "콘크리트",
"inlet.form=돌망태": "돌망태",
"inlet.form=통나무·목재틀": "통나무/목재틀",
"inlet.form=바자": "바자",
"inlet.length": "길이 (m)",
"inlet.height": "높이 (m)",
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
"outlet.structure=흙막이": "흙막이",
"outlet.form": "형태",
"outlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"outlet.form=돌쌓기(메)": "돌 쌓기(메)",
"outlet.form=콘크리트": "콘크리트",
"outlet.form=돌망태": "돌망태",
"outlet.form=통나무·목재틀": "통나무/목재틀",
"outlet.form=바자": "바자",
"outlet.length": "길이 (m)",
"outlet.height": "높이 (m)",
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"soil_add": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
"soil_add.form=돌쌓기(메)": "돌 쌓기(메)",
"soil_add.form=콘크리트": "콘크리트",
"soil_add.form=돌망태": "돌망태",
"soil_add.form=통나무·목재틀": "통나무/목재틀",
"soil_add.form=바자": "바자",
"soil_add.length": "길이 (m)",
"soil_add.height": "높이 (m)",
"soil_add.before": "기준측점 전 (m)",
"soil_add.after": "기준측점 후 (m)",
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
}
},
"ford_pavement": {
@@ -3405,17 +3453,55 @@
"ford.diameter=1000": "1000",
"ford.diameter=1200": "1200",
"ford.diameter=1500": "1500",
"ford.count": "수량 (련)",
"ford.width": "월류 폭 (m)",
"ford.height": "월류 높이 (m)",
"ford.count": "수량 (련)",
"inlet.structure": "구조",
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"inlet.form": "형태",
"inlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"inlet.form=돌쌓기(메)": "돌 쌓기(메)",
"inlet.form=콘크리트": "콘크리트",
"inlet.form=돌망태": "돌망태",
"inlet.form=통나무·목재틀": "통나무/목재틀",
"inlet.form=바자": "바자",
"inlet.length": "길이 (m)",
"inlet.height": "높이 (m)",
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
"outlet.structure=흙막이": "흙막이",
"outlet.form": "형태",
"outlet.form=돌쌓기(찰)": "돌 쌓기(찰)",
"outlet.form=돌쌓기(메)": "돌 쌓기(메)",
"outlet.form=콘크리트": "콘크리트",
"outlet.form=돌망태": "돌망태",
"outlet.form=통나무·목재틀": "통나무/목재틀",
"outlet.form=바자": "바자",
"outlet.length": "길이 (m)",
"outlet.height": "높이 (m)",
"outlet.before": "기준측점 전 (m)",
"outlet.after": "기준측점 후 (m)",
"soil_add": "흙막이",
"soil_add.form": "형태",
"soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)",
"soil_add.form=돌쌓기(메)": "돌 쌓기(메)",
"soil_add.form=콘크리트": "콘크리트",
"soil_add.form=돌망태": "돌망태",
"soil_add.form=통나무·목재틀": "통나무/목재틀",
"soil_add.form=바자": "바자",
"soil_add.length": "길이 (m)",
"soil_add.height": "높이 (m)",
"soil_add.before": "기준측점 전 (m)",
"soil_add.after": "기준측점 후 (m)",
"chute_add": "도수로/돌붙임",
"chute_add.structure": "구조",
"chute_add.structure=도수로": "도수로",
"chute_add.structure=돌붙임": "돌붙임"
}
},
"revetment": {
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
/* 박스암거 명세 단위검증 헬퍼(PLAN 52-2) — `design_structure_spec.ts` · `design_structure_box.ts` 를
* 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌.
* 그 자리에서 트랜스파일해 Node 로 돌림(같은 폴더 명세 import 는 따라 옮김). 결과 = JSON 한 벌.
* 인자 `names` 면 박스암거 이름 표(정의 `종류.box_culvert.이름`)만 냄.
*/
const fs = require("fs");
@@ -13,7 +13,9 @@ const load = (file) => {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
// 같은 폴더 명세(흙막이 양식 · 65-2)는 그 자리에서 옮김
const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m));
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
const s = load("design_structure_spec.ts");
@@ -130,6 +130,7 @@ const STUBS = {
// 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만
"./B05_Profile_UI_Drainage_Revet_Choice": {
revetChoiceRow: () => ({ paint: () => new FakeNode("div") }),
guardChoiceFields: () => () => [],
},
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": {
buildSummaryRow: () => {
+94
View File
@@ -0,0 +1,94 @@
/* 65장 세월교 · 박스암거 · 물넘이 포장 명세 단위검증 헬퍼 — `design_structure_*.ts` 를 그 자리에서 트랜스파일해
* Node 로 돌림(같은 폴더 명세 import 는 따라 옮김). 결과 = JSON 한 벌. 짝 = `test_65_ford_defaults.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
const base = m.startsWith("@config/")
? path.join(ROOT, "config", m.slice(8))
: path.join(path.dirname(file), m);
return load(`${base}.ts`);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
const D = (f) => path.join(ROOT, "A00_Common", "design", f);
const s = load(D("design_structure_spec.ts"));
const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts"));
const { BOX_SPEC } = load(D("design_structure_box.ts"));
const { FORD_PAVEMENT_SPEC } = load(D("design_structure_ford_pavement.ts"));
const calc = load(D("design_structure_ford_calc.ts"));
/** 상자마다 칸 id · 갈래(구조 값 → 저장 키) — 칸 목록 대조용. */
const items = (spec) =>
Object.fromEntries(
spec.containers.map((c) => [
c.id,
c.items.map((i) => [i.id, i.by ? Object.keys(i.by.map) : null, i.default ?? null]),
]),
);
const none = (spec) =>
Object.fromEntries(
spec.containers.map((c) => [
c.id,
[s.isNone(c, { structure: "없음" }), s.isNone(c, { structure: "흙막이" })],
]),
);
// 세월교 유입 흙막이 · 유출 없음 · 추가 흙막이 1 · 도수로 1 → 저장 → 다시 읽기
const fordState = s.readState(FORD_SPEC, withLegacyGuards({ pipe_diameter_mm: 1000 }));
fordState.inlet[0] = {
structure: "흙막이",
form: "돌망태",
length: "8",
height: "1.2",
before: "3",
after: "5",
};
fordState.outlet[0] = { structure: "없음" };
fordState.soil_add.push({ form: "바자", length: "6", height: "1", before: "3", after: "3" });
fordState.chute_add.push({ structure: "돌붙임" });
const fordOut = s.writeOptions(FORD_SPEC, fordState);
const fordBack = s.readState(FORD_SPEC, withLegacyGuards(fordOut));
// 박스 유출 흙막이 → 저장 키
const boxState = s.readState(BOX_SPEC, {});
boxState.outlet[0] = { structure: "흙막이", form: "콘크리트", length: "4", height: "2" };
const boxOut = s.writeOptions(BOX_SPEC, boxState);
// 물넘이 포장 경사각 · 월류 폭 · 높이
const paveState = s.readState(FORD_PAVEMENT_SPEC, { ford_slope_deg: 3, ford_width_m: 5 });
paveState.slope[0].height = String(calc.fordHeightM(2.54, 5));
const paveOut = s.writeOptions(FORD_PAVEMENT_SPEC, paveState);
process.stdout.write(
JSON.stringify({
ford: items(FORD_SPEC),
box: items(BOX_SPEC),
pave: items(FORD_PAVEMENT_SPEC),
fordNone: none(FORD_SPEC),
boxNone: none(BOX_SPEC),
fordOut,
fordBack,
boxOut,
paveOut,
paveRetired: FORD_PAVEMENT_SPEC.retired,
fordKeys: s.specKeys(FORD_SPEC),
boxKeys: s.specKeys(BOX_SPEC),
paveKeys: s.specKeys(FORD_PAVEMENT_SPEC),
table: [0.5, 2.54, 8, 30].flatMap((q) =>
[3, 5, 10, 20].map((b) => [q, b, calc.fordHeightM(q, b)]),
),
}),
);
+5 -1
View File
@@ -75,13 +75,17 @@ def test_집계표_채움에_컨테이너_줄(world):
def test_서버_컨테이너_표는_배관_명세와_짝():
with open(
os.path.join(ROOT, "A00_Common/design/design_structure_pipe.ts"), encoding="utf-8"
os.path.join(ROOT, "A00_Common/design/design_structure_guard.ts"), encoding="utf-8"
) as f:
text = f.read()
found = re.findall(
r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text
)
assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["pipe"])
# 박스 · 세월교 = 같은 흙막이 양식(65-2)
assert (
SPEC_CONTAINERS["box_culvert"] == SPEC_CONTAINERS["ford_bridge"] == SPEC_CONTAINERS["pipe"]
)
def test_54_11_흙막이는_선_단_수까지만():
@@ -70,22 +70,23 @@ def test_컨테이너_경사각도_필수_도수로돌붙임_선택(out):
def test_저장_값은_도_와_인스턴스_키(out):
assert out["state"] == {"slope": [{"angle": "12.5"}], "chute": [{"structure": "돌붙임"}]}
slope = {
"angle": "12.5",
"width": "6",
"height": "0.3",
} # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5)
assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임"}]}
assert out["written"] == {
"ford_slope_deg": 15,
"ford_width_m": 6,
"ford_height_m": 0.3,
"ford_chute_1_structure": "돌붙임",
"ford_chute_2_structure": "도수로",
}
def test_옛_칸은_지난_정의_값으로_남음(out):
assert out["retired"] == [
"ford_width_m",
"ford_height_m",
"ford_slope_pct",
"thickness_cm",
"length_m",
]
assert out["retired"] == ["ford_slope_pct", "thickness_cm", "length_m"]
# 폼이 모르는 옛 칸은 그대로 · 폼이 아는 머리(빠진 인스턴스)는 폼 값으로 갈아 끼움
assert out["saved"] == {
"ford_width_m": 6,
@@ -95,7 +96,12 @@ def test_옛_칸은_지난_정의_값으로_남음(out):
"length_m": 7,
**out["written"],
}
assert out["formKeys"] == out["keys"]["keys"] == ["ford_slope_deg"]
# 월류 폭 · 높이 = 세월교와 같은 키(65-5)
assert (
out["formKeys"]
== out["keys"]["keys"]
== ["ford_slope_deg", "ford_width_m", "ford_height_m"]
)
assert out["heads"] == out["keys"]["prefixes"] == ["ford_chute_"]
@@ -39,17 +39,20 @@ def test_컨테이너_셋_모두_필수(out):
["ford", "세월교", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
["soil_add", "흙막이", False],
["chute_add", "도수로/돌붙임", False],
]
# 수량(련) = 관경 바로 뒤(65-4)
assert out["items"][0] == [
["diameter", "select", "pipe_diameter_mm"],
["count", "number", "pipe_count"],
["width", "number", "ford_width_m"],
["height", "number", "ford_height_m"],
["count", "number", "pipe_count"],
]
assert out["choices"] == [
[["800", "1000", "1200", "1500"]],
[["없음", "날개벽", "흙막이"]],
[["없음", "날개벽", "흙막이"]],
assert [c[0] for c in out["choices"][:3]] == [
["800", "1000", "1200", "1500"],
["없음", "날개벽", "흙막이"],
["없음", "날개벽", "흙막이"],
]
@@ -73,7 +76,7 @@ def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out):
def test_잇기_키_컨테이너마다(out):
assert out["links"] == ["gford", "gfordin", "gfordout"]
assert out["links"] == ["gford", "gfordin", "gfordout", "gsoiladd1", "gchuteadd1"]
def _manning_depth(q, b):
@@ -36,8 +36,10 @@ def test_컨테이너_셋_모두_필수(out):
["body", "본체", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
["soil_add", "흙막이", False],
["chute_add", "도수로/돌붙임", False],
]
assert out["links"] == ["gboxbody", "gboxin", "gboxout"]
assert out["links"] == ["gboxbody", "gboxin", "gboxout", "gsoiladd1", "gchuteadd1"]
def test_선택지_표_02_그대로(out):
@@ -56,9 +56,12 @@ def test_상자_칸을_고치면_그_부분을_고름() -> None:
def test_흙막이_상자는_사면안정_흙막이와_같은_칸() -> None:
pipe = _src("A00_Common/design/design_structure_pipe.ts")
soil = pipe[pipe.index('id: "soil_add"') :]
assert soil[: soil.index("},")].count("items: guardItems(),") == 1
# 흙막이 양식 한 벌(65-2) — 배관 · 박스 · 세월교가 같은 상자를 씀
guard = _src("A00_Common/design/design_structure_guard.ts")
soil = guard[guard.index('id: "soil_add"') :]
assert soil[: soil.index("};")].count("items: guardItems(),") == 1
for spec in ("pipe", "box", "ford"):
assert "SOIL_ADD" in _src(f"A00_Common/design/design_structure_{spec}.ts")
def test_관_보호공_연동_경사_단추() -> None:
+153
View File
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""65장 세월교 · 박스암거 · 물넘이 포장 — 명세 · 기본값 · 자동 배치 월류 높이.
못박는 것
- 세월교 · 박스 유입 · 유출 = 구조 + 흙막이 칸(형태 · 길이 · 높이 · 전 · 후)만 · 높이 · 측벽 · 바닥판 칸 없음
- 흙막이 칸 기본값(찰 · 10 · 1.5 · 5 · 5) · 추가 흙막이 · 도수로/돌붙임 상자 · 키 `{side}_revet_*`
- 「없음」 = 없는 상자(목록 램프 · 잇기 줄 뺌)
- 세월교 칸 차례 관경 → 수량(련) → 월류 폭 → 높이 · 정의 기본값 관경 1000 · 폭 10 · 련 2
- 물넘이 포장 경사각 기본 3° · 월류 폭 · 높이 = 세월교와 같은 키
- 자동 배치 새 세월교 빈 월류 높이 = 필요 수심(서버 `ford_height_m` ↔ TS `fordHeightM` 거울)
- B06 측벽 읽기 줄은 세월교 · 박스 유입 · 유출 칸에 안 옮김
화면(ORCA)은 따로 봄.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import PipePoint
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).resolve().parents[1]
GUARD = [
["structure", None, None],
["form", ["흙막이"], "돌쌓기(찰)"],
["length", ["흙막이"], "10"],
["height", ["흙막이"], "1.5"],
["before", ["흙막이"], "5"],
["after", ["흙막이"], "5"],
]
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_65_ford_box.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_유입_유출은_구조와_흙막이_칸만(out):
for spec in ("ford", "box"):
for side in ("inlet", "outlet"):
assert out[spec][side] == GUARD, (spec, side)
assert [i[0] for i in out[spec]["soil_add"]] == [
"form",
"length",
"height",
"before",
"after",
]
assert out[spec]["chute_add"] == [["structure", None, None]]
keys = out[f"{spec}Keys"]
assert keys["prefixes"] == ["soil_add_", "chute_add_"]
# 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음
assert not [
k for k in keys["keys"] if "wall" in k or "slab" in k or k.startswith("wing_in_")
]
def test_없음이면_없는_상자(out):
for spec in ("fordNone", "boxNone"):
assert out[spec]["inlet"] == [True, False] and out[spec]["outlet"] == [True, False]
assert out[spec]["soil_add"] == [False, False]
def test_흙막이_저장_키와_다시_열기(out):
o = out["fordOut"]
assert o["inlet_type"] == "흙막이" and o["wing_in"] == "없음"
assert (o["inlet_revet_form"], o["inlet_revet_length_m"], o["inlet_revet_height_m"]) == (
"돌망태",
8,
1.2,
)
assert o["outlet_type"] == "없음" and not any(k.startswith("outlet_revet_") for k in o)
assert o["soil_add_1_form"] == "바자" and o["chute_add_1_structure"] == "돌붙임"
back = out["fordBack"]
assert back["inlet"][0]["length"] == "8" and back["soil_add"][0]["form"] == "바자"
assert out["boxOut"]["wing_out"] == "흙막이" and out["boxOut"]["outlet_revet_length_m"] == 4
def test_세월교_칸_차례와_정의_기본값(out):
assert [i[0] for i in out["ford"]["ford"]] == ["diameter", "count", "width", "height"]
ford = {o.key: o.default for o in structure_type_map()["ford_bridge"].options}
assert (str(ford["pipe_diameter_mm"]), ford["ford_width_m"], ford["pipe_count"]) == (
"1000",
10,
2,
)
def test_물넘이_경사각_기본_3_월류_폭_높이_같은_키(out):
assert [i[0] for i in out["pave"]["slope"]] == ["angle", "width", "height"]
assert out["paveOut"] == {"ford_slope_deg": 3, "ford_width_m": 5, "ford_height_m": 0.12}
assert "ford_width_m" not in out["paveRetired"] and "ford_height_m" not in out["paveRetired"]
pave = {o.key: o.default for o in structure_type_map()["ford_pavement"].options}
assert pave["ford_slope_deg"] == 3
def test_서버_월류_높이_거울(out):
for q, b, h in out["table"]:
assert basins.ford_height_m(q, b) == h, (q, b)
assert basins.ford_height_m(None, 10) is None and basins.ford_height_m(2.54, 0) is None
def test_자동_배치_새_세월교는_기본값과_월류_높이():
fresh = PipePoint(chainage_m=120.0, source="stream", facility="ford_bridge")
basins._apply_new_defaults([fresh], {120.0: 8.0})
o = fresh.options
assert (str(o["pipe_diameter_mm"]), o["ford_width_m"], o["pipe_count"]) == ("1000", 10, 2)
assert o["ford_height_m"] == basins.ford_height_m(8.0, 10) == 0.16
kept = PipePoint(
chainage_m=120.0, source="stream", facility="ford_bridge", options={"ford_height_m": 0.5}
)
basins._apply_new_defaults([kept], {120.0: 8.0})
assert kept.options["ford_height_m"] == 0.5 # 있는 값은 그대로
blind = PipePoint(chainage_m=140.0, source="stream", facility="ford_bridge")
basins._apply_new_defaults([blind], {})
assert "ford_height_m" not in blind.options # 유량 없으면 빈칸
def test_집계표_세월교_흙막이_상자_건():
item = {
"type_id": "ford_bridge",
"options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"},
"복사본": "pipe@50.0",
"흙막이단": 0, # 배관 단 수는 세월교에 안 걸림
}
rows = container_rows(item, {"gsoiladd2": "SS000001"})
assert [(r["그룹"], r["summary_item"]) for r in rows] == [
("흙막이 1", None),
("흙막이 2", "SS000001"),
]
def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김():
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock
lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8")
assert "return isNone(c, state[c.id][0])" in lists
@@ -0,0 +1,93 @@
/* 65 — B05 · B06 좌측 「구조물 배치」 세월교 · 박스암거 · 물넘이 포장(PLAN 65-1 ~ 65-5)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(검증_sub5) · 목록에 「세월교 1」
* 부르는 법: orca eval --expression "window.__m65='단계'; $(cat 이 파일)"
* shape — 세월교 1 고름 → 상자 · 칸 · 램프 수
* edit — 유입 흙막이(기본값 칸) · 유출 없음(램프 줄어듦) · 추가 흙막이 · 도수로 · [저장]
* verify — 다시 연 값 · 램프 수
* newford — 새 세월교(관경 · 련 · 폭 · 높이 자동) · 새 물넘이(경사각 3 · 폭 · 높이) — 저장 안 함
* 되돌림: 검증 프로젝트 파일을 미리 떠 둔 사본으로(스크립트 밖)
*/
(async () => {
const MODE = window.__m65 || "shape";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c, i = 0) => spec()?.querySelectorAll(`[data-spec-container="${c}"]`)[i];
const ctl = (c, item, i = 0) =>
box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input");
const set = async (c, item, v, i = 0) => {
const el = ctl(c, item, i);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(300);
};
const rows = () =>
[...document.querySelectorAll(".b05-route__irregular-list li")].filter((l) => l.offsetParent);
const row = (re) => rows().find((l) => re.test(l.textContent.trim()));
const lamps = (re) => row(re)?.querySelectorAll(".b05-route__lamps i").length ?? -1;
const pick = async (re) => {
await until(() => !!row(re));
const li = row(re);
(li.querySelector("button") ?? li).click();
await w(800);
await until(() => !!spec());
await w(400);
};
const shape = () =>
[...spec().querySelectorAll("[data-spec-container]")].map((b) => ({
c: b.dataset.specContainer,
legend: b.querySelector("legend")?.firstChild?.textContent,
items: [...b.querySelectorAll("[data-spec-item]")]
.filter((n) => !n.hidden)
.map((n) => [n.dataset.specItem, n.querySelector("select, input")?.value ?? ""]),
fields: [...b.querySelectorAll(".b05-route__field")]
.filter((n) => !n.hidden && n.offsetParent)
.map((n) => n.querySelector("span")?.textContent),
link: !!b.querySelector("[data-spec-link]"),
option: [...b.querySelectorAll(".b05-structure__half-pair button")].map(
(x) => `${x.textContent}${x.disabled ? "(끔)" : ""}`,
),
}));
const out = { mode: MODE };
const FORD = /세월교 1$/;
if (MODE === "shape") {
await pick(FORD);
out.shape = shape();
out.lamps = lamps(FORD);
}
if (MODE === "edit") {
await pick(FORD);
out.lampsBefore = lamps(FORD);
await set("inlet", "structure", "흙막이");
out.inlet = shape().find((b) => b.c === "inlet");
await set("outlet", "structure", "없음");
await w(800);
out.outlet = shape().find((b) => b.c === "outlet");
out.lampsAfterNone = lamps(FORD);
for (const c of ["soil_add", "chute_add"]) {
spec().querySelector(`[data-spec-add="${c}"]`)?.click();
await w(400);
}
await set("chute_add", "structure", "도수로");
await set("inlet", "length", "8");
out.lampsAfterAdd = lamps(FORD);
out.edited = shape();
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
}
if (MODE === "verify") {
await pick(FORD);
out.reopened = shape();
out.lamps = lamps(FORD);
}
return JSON.stringify(out);
})();