Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8

This commit is contained in:
2026-10-05 23:43:58 +09:00
10 changed files with 590 additions and 64 deletions
+19 -1
View File
@@ -3321,7 +3321,25 @@
"계곡통과시설",
"월류단면",
"날개벽"
]
],
"이름": {
"ford.diameter": "관경",
"ford.diameter=800": "800",
"ford.diameter=1000": "1000",
"ford.diameter=1200": "1200",
"ford.diameter=1500": "1500",
"ford.width": "월류 폭 (m)",
"ford.height": "월류 높이 (m)",
"ford.count": "수량 (련)",
"inlet.structure": "구조",
"inlet.structure=없음": "없음",
"inlet.structure=날개벽": "날개벽",
"inlet.structure=흙막이": "흙막이",
"outlet.structure": "구조",
"outlet.structure=없음": "없음",
"outlet.structure=날개벽": "날개벽",
"outlet.structure=흙막이": "흙막이"
}
},
"revetment": {
"동작": [
+86
View File
@@ -0,0 +1,86 @@
/* 세월교 명세(표 04) · 월류 폭 ↔ 높이 계산 단위검증 헬퍼(PLAN 52-4) — `design_structure_spec.ts` ·
* `design_structure_ford.ts` · `design_structure_ford_calc.ts`(+ `fordSection` 이 든 B05 칸 조각)를 그 자리에서
* 트랜스파일해 Node 로 돌림. 결과 = JSON 한 벌. 인자 `names` 면 세월교 이름 표(정의 `종류.ford_bridge.이름`)만 냄.
*/
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();
/** TS 한 파일을 CommonJS 로 — 상대 import 는 그 파일 자리 기준 · `@config/` 는 config 폴더 · .json 은 그대로. */
const load = (file) => {
if (cache.has(file)) return cache.get(file);
if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8"));
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.endsWith(".json") ? base : `${base}.ts`);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
cache.set(file, box.exports);
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 calc = load(D("design_structure_ford_calc.ts"));
if (process.argv[2] === "names") {
process.stdout.write(JSON.stringify(s.nameTable(FORD_SPEC)));
process.exit(0);
}
const old = {
pipe_kind: "파형강관",
pipe_diameter_mm: 1000,
pipe_count: 2,
ford_width_m: 10,
ford_height_m: 0.3,
wing_in: "있음",
wing_in_height_m: 1,
wing_out: "없음",
};
const legacy = withLegacyGuards(old);
const state = s.readState(FORD_SPEC, legacy);
const back = s.writeOptions(FORD_SPEC, state);
const soil = JSON.parse(JSON.stringify(state));
soil.inlet[0].structure = "흙막이";
soil.outlet[0].structure = "날개벽";
const soilOut = s.writeOptions(FORD_SPEC, soil);
const flows = [0.5, 2.54, 8];
const widths = [3, 5, 10, 20];
const table = flows.flatMap((q) => widths.map((b) => [q, b, calc.fordHeightM(q, b)]));
const out = {
containers: FORD_SPEC.containers.map((c) => [c.id, c.title, c.required]),
items: FORD_SPEC.containers.map((c) => c.items.map((i) => [i.id, i.kind, i.key ?? null])),
choices: FORD_SPEC.containers.map((c) =>
c.items.filter((i) => i.kind === "select").map((i) => s.choicesOf(i, {}).map((ch) => ch.label)),
),
legacy: { inlet: legacy.inlet_type, outlet: legacy.outlet_type },
keepSoil: withLegacyGuards({ inlet_type: "흙막이", wing_in: "없음" }).inlet_type,
b06Off: withLegacyGuards({ inlet_type: "날개벽", wing_in: "없음" }).inlet_type,
b06On: withLegacyGuards({ inlet_type: "없음", wing_in: "있음" }).inlet_type,
state,
back,
soilOut,
links: FORD_SPEC.containers.map((c) => s.linkOf(c, 1)),
keys: s.specKeys(FORD_SPEC),
retired: FORD_SPEC.retired,
table,
none: [calc.fordHeightM(null, 10), calc.fordHeightM(2.54, 0)],
hints: [
calc.fordHeightHint(2.54, 10),
calc.fordHeightHint(2.54, 0),
calc.fordHeightHint(null, 10),
],
};
process.stdout.write(JSON.stringify(out));
@@ -13,7 +13,8 @@ const load = (file, deps = {}) => {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m));
const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); // 같은 폴더 명세
new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? near(m));
return box.exports;
};
const s = load("design_structure_spec.ts");
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""횡단배수 › 세월교(표 04) 명세 + 월류 폭 ↔ 높이 연동 — PLAN 52-4.
순수 규칙은 Node 헬퍼(`helper_52_4_ford.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-4_세월교_컨테이너.js`.
높이 거울 = 이 파일이 Manning(통수단면.md §2 · n 0.017 · S 0.10)을 따로 풀어 같은 값인지 봄.
"""
import json
import math
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
N, S = 0.017, 0.10
def _node(*args):
proc = subprocess.run(
["node", os.path.join(HERE, "helper_52_4_ford.cjs"), *args],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.fixture(scope="module")
def out():
return _node()
def test_컨테이너_셋_모두_필수(out):
assert out["containers"] == [
["ford", "세월교", True],
["inlet", "유입 구조", True],
["outlet", "유출 구조", True],
]
assert out["items"][0] == [
["diameter", "select", "pipe_diameter_mm"],
["width", "number", "ford_width_m"],
["height", "number", "ford_height_m"],
["count", "number", "pipe_count"],
]
assert out["choices"] == [
[["800", "1000", "1200", "1500"]],
[["없음", "날개벽", "흙막이"]],
[["없음", "날개벽", "흙막이"]],
]
def test_옛_날개벽_칸으로_구조를_세우고_같은_키로_씀(out):
assert out["legacy"] == {"inlet": "날개벽", "outlet": "없음"}
assert out["keepSoil"] == "흙막이" # 흙막이는 설치 칸 없음이어도 그대로
assert out["b06Off"] == "없음" and out["b06On"] == "날개벽" # B06 조정창 설치 칸을 따름
back = out["back"]
assert back["pipe_diameter_mm"] == 1000 and back["pipe_count"] == 2
assert back["ford_width_m"] == 10 and back["ford_height_m"] == 0.3
assert back["wing_in"] == "있음" and back["wing_out"] == "없음"
# 표 04 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "pipe_kind" not in back and "wing_in_height_m" not in back
assert "pipe_kind" in out["retired"]
def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out):
s = out["soilOut"]
assert s["inlet_type"] == "흙막이" and s["wing_in"] == "없음"
assert s["outlet_type"] == "날개벽" and s["wing_out"] == "있음"
def test_잇기_키_컨테이너마다(out):
assert out["links"] == ["gford", "gfordin", "gfordout"]
def _manning_depth(q, b):
"""넓은 직사각 수로 Manning 을 h 에 대해 이분법으로 풂 — 헬퍼와 다른 풀이."""
def cap(h):
return b * h * (1 / N) * ((b * h) / (b + 2 * h)) ** (2 / 3) * math.sqrt(S)
lo, hi = 0.0, 5.0
for _ in range(100):
mid = (lo + hi) / 2
lo, hi = (mid, hi) if cap(mid) < q else (lo, mid)
return hi
def test_월류_높이는_폭과_연동_필요_수심_올림(out):
for q, b, h in out["table"]:
need = _manning_depth(q, b)
assert h == pytest.approx(math.ceil(need * 100 - 1e-9) / 100), (q, b)
# 폭이 넓을수록 높이가 낮아짐
row = [h for q, b, h in out["table"] if q == 2.54]
assert row == sorted(row, reverse=True) and len(set(row)) == len(row)
assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력
assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"]
def test_정의_파일에_세월교_이름_표가_올라_있음():
names = _node("names")
with open(
os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8"
) as f:
doc = json.load(f)
assert doc["종류"]["ford_bridge"]["이름"] == names
@@ -0,0 +1,181 @@
/* 52-4 — B05 · B06 좌측 「구조물 배치」 세월교 = 사용자 구조물 표 04 명세(컨테이너 셋)
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 목록에 「세월교 1」
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m524 = "단계";` 를 붙여
* orca eval --expression "window.__m524='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠)
* (orca reload 로 다시 열기)
* orca eval --expression "window.__m524='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기
* orca eval --expression "window.__m524='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치(edit): 컨테이너 3(세월교 · 유입 구조 · 유출 구조 · 모두 필수) · 관경 800/1000/1200/1500 ·
* 구조 없음/날개벽/흙막이 · 옛 관종 · 날개벽 칸 안 보임 · 폭 8 → 높이 = 필요 수심(설계유량 있으면) ·
* 낮게 적으면 필요 수심으로 되돌림 · 수량 3 · 잇기 세월교 · 유입 지정 · 유출 지정 뒤 풂
* 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06)
*/
(async () => {
const MODE = window.__m524 || "edit";
const NAME = /세월교 1$/;
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 projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"));
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c) => spec().querySelector(`[data-spec-container="${c}"]`);
const ctl = (c, item) =>
box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input");
const set = async (c, item, v) => {
const el = ctl(c, item);
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(250);
};
const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text);
const pickFord = async () => {
const find = () =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find(
(l) => l.offsetParent !== null && NAME.test(l.textContent.trim()),
);
await until(() => !!find());
const li = find();
(li.querySelector("button") ?? li).click();
await w(800);
if (!spec()) (li.querySelector("button") ?? li).click();
await until(() => !!spec());
await w(500);
};
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]")].map((n) => [
n.dataset.specItem,
n.querySelector("select, input").value,
]),
link: b.querySelector(".b05-structure__summary-name")?.textContent,
}));
const assigned = (c) =>
box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned");
const pickLink = async (c) => {
box(c).querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(500);
};
const visibleKeys = () =>
[...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")]
.filter((n) => n.offsetParent !== null)
.map((n) => n.dataset.optionKeys);
const out = { mode: MODE };
if (MODE === "edit") {
const before = await fetchApi.fetchDetailPipePoints(projectId);
sessionStorage.setItem("__524_original", JSON.stringify(before.pipe_points));
await pickFord();
const s0 = shape();
out.containers = s0.map((b) => [b.c, b.legend]);
out.oldFieldsVisible = visibleKeys(); // 옛 관종 · 월류 · 날개벽 칸 — 빈 목록이어야
out.diameters = labels("ford", "diameter");
out.inletChoices = labels("inlet", "structure");
out.outletChoices = labels("outlet", "structure");
out.start = s0.map((b) => b.items);
await set("ford", "diameter", "1200");
await set("ford", "width", "8");
out.heightAfterWidth8 = ctl("ford", "height").value;
out.heightHint = ctl("ford", "height").placeholder;
await set("ford", "width", "4");
out.heightAfterWidth4 = ctl("ford", "height").value;
await set("ford", "height", "0.01");
out.heightLowBounced = ctl("ford", "height").value;
const raised = (Number(out.heightAfterWidth4 || 0) + 0.2).toFixed(2);
await set("ford", "height", raised);
out.heightRaisedKept = ctl("ford", "height").value === raised;
await set("ford", "count", "3");
await set("inlet", "structure", "흙막이");
await set("outlet", "structure", "날개벽");
await pickLink("ford");
await pickLink("inlet");
await pickLink("outlet");
out.linksPicked = ["ford", "inlet", "outlet"].map(assigned);
box("outlet").querySelector('[data-b05-summary="clear"]').click();
await w(300);
out.linkOutletCleared = !assigned("outlet");
out.edited = shape().map((b) => b.items);
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
const after = await fetchApi.fetchDetailPipePoints(projectId);
const p = after.pipe_points.find((x) => x.chainage_m > 170 && x.chainage_m < 176); // 세월교 1 = 8+13
out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) };
}
if (MODE === "verify") {
await pickFord();
const b05 = shape();
out.reopened = b05.map((b) => b.items);
out.links = ["ford", "inlet", "outlet"].map(assigned);
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await pickFord();
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
out.b06WingSlots = ["inlet", "outlet"].map(
(c) => box(c).querySelector(".b05-structure__adjust-slot")?.childElementCount ?? -1,
);
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__524_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const keys = [
"chainage_m",
"source",
"facility",
"start_m",
"end_m",
"options",
"summary_item",
"group_items",
];
const points = original.map((p) =>
Object.fromEntries(
keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]),
),
);
await fetchApi.saveDetailPipePoints(projectId, points);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("aislo:draft:pipes:") || k === "__524_original")
sessionStorage.removeItem(k);
const now = await fetchApi.fetchDetailPipePoints(projectId);
const pick = (list) =>
JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}]));
out.restored = pick(now.pipe_points) === pick(original);
}
return JSON.stringify(out);
})();