fix(B06): 세월교 유입 · 유출 따로 움직임 — 관 1 m 맞춤 기본 자리 기준 · 벽 · 본체 동시 고름 금지(107-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 16:16:25 +09:00
co-authored by Claude Opus 5.5
parent 52994b33af
commit 79249b8820
9 changed files with 194 additions and 16 deletions
+17 -9
View File
@@ -43,9 +43,11 @@ export interface BodyGuardBase {
trim: CulvertDesignTrim;
}
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
defaults = false,
): BodyGuardBase | null {
const body = section.culvert?.body;
const adjust = section.design;
@@ -74,9 +76,10 @@ export function bodyGuardBase(
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
}
if (body === "ford" && section.ford) {
const stored = defaults ? null : adjust?.ford_adjust;
const layout = computeFordLayout(section, samples, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
});
if (!layout) return null;
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
@@ -143,16 +146,21 @@ export function bodyGuardAxes(
export type BodyAxes = { x: number; d: number; through: number; outward: number };
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
* 수량 L 은 그린 관 길이 올림 그대로). */
export function fordPipeFit(
base: BodyGuardBase | null,
section: CrossSection,
samples: SectionSample[],
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
axesOf: (role: Role) => BodyAxes | null,
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
): [BodyAxes | null, BodyAxes | null] {
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
if (!base || culvert.body !== "ford") return axes;
if (culvert.body !== "ford") return axes;
const base = bodyGuardBase(section, samples, true);
if (!base) return axes;
const roles = ["inlet", "outlet"] as const;
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null));
const tips = roles.map((role, i) => {
const end = base.ends[role];
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
@@ -163,8 +171,8 @@ export function fordPipeFit(
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
const gap = Math.ceil(length - 1e-6) - length;
const at = guarded[1] ? 1 : 0;
const fit = guarded[at];
if (!fit || gap < 1e-6) return axes;
const fit = axes[at];
if (!guarded[at] || !fit || gap < 1e-6) return axes;
axes[at] = { ...fit, x: fit.x + gap };
return axes;
}
@@ -172,8 +172,8 @@ export function computeCulvertLayout(
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
const bodyAxesOf = (role: "inlet" | "outlet") => {
const end = body?.ends[role];
const bodyAxesOf = (role: "inlet" | "outlet", base = body) => {
const end = base?.ends[role];
const [spec, info] =
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
if (!end) return null;
@@ -186,7 +186,7 @@ export function computeCulvertLayout(
thickness: REVET_THICKNESS_M,
});
};
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf);
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
+5 -4
View File
@@ -213,6 +213,7 @@ export function createCrossSectionCard(
box,
isCardSelected: () => isSelected,
clearArea: () => {
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
if (activeArea === null) return;
activeArea = null;
setBandActive(null);
@@ -235,10 +236,10 @@ export function createCrossSectionCard(
setChipActive(null);
}
if (activeRevet !== null) {
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
// 면적 강조를 이미 비운다.
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
if (wasSelected) onAreaSelect?.(section.station_id, null);
else onSelect(section.station_id);
}
@@ -109,6 +109,8 @@ export interface FordControlDeps {
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
round1: (value: number) => number;
clampMove: (value: number) => number;
/** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */
releaseWall: (chainageM: number) => void;
}
export interface FordControls {
@@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
fordSelections.set(chainageM.toFixed(2), role);
},
};
@@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): {
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
if (role) deps.releaseWall(chainageM);
selections.set(chainageM.toFixed(2), role);
},
};
@@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
select: (chainageM, key) => {
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
const { ford, box } = bodyControls;
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
revetOffsetControl.onSelectionChange?.();
},
syncApplied: (chainageM, role, applied) => {
@@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
releaseWall: (chainageM) => {
if (revetSelection?.at !== chainageM.toFixed(2)) return;
revetSelection = null;
revetOffsetControl.onSelectionChange?.();
},
},
deps.sessionKey,
);
+72
View File
@@ -0,0 +1,72 @@
/* 107 세월교 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_107_ford_free.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 STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
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 localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", f);
const out = {};
// 107-1 — 세월교 · 박스를 고르면 같은 측점 벽 고름을 풂(푸는 것은 안 부름)
const { createBodyControls } = loadTs(B06("B06_Section_UI_Page_Ford_Controls.ts"));
const released = [];
const bodies = createBodyControls(
{
sectionAt: () => undefined,
patchCachedDesign: () => undefined,
refreshCard: () => undefined,
queuePipeOptions: () => undefined,
round1: (v) => Math.round(v * 10) / 10,
clampMove: (v) => v,
releaseWall: (chainageM) => released.push(chainageM),
},
() => null,
);
bodies.ford.control.select(173.09, "inlet");
bodies.ford.control.select(173.09, null);
bodies.box.control.select(352.14, "left");
out.released = released;
process.stdout.write(JSON.stringify(out));
+22
View File
@@ -107,6 +107,28 @@ const out = {
}),
};
// 107-1 — 유입 날개벽(측벽) 좌우 · 상하를 옮겨도 유출 흙막이 자리 그대로(관 1 m 맞춤 = 기본 자리 기준)
const outletWallAt = (inlet) => {
const section = clone(FORD);
section.culvert.inlet = { role: "inlet", structure: "" };
section.design.ford_adjust = { inlet: { heightM: null, ...inlet } };
const { ford, culvert } = computeStoredLayouts(
section,
sections.map((s) => (s === FORD ? section : s)),
);
const wall = culvert.walls.find((w) => w.role === "outlet");
const [right, left] = ford.pipe.outer.slice(2);
return {
back: r3(wall.bottomBack.offset),
label: ford.label.text,
lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)),
};
};
out.inletMoves = [0, 0.1, 0.2, 0.3, 0.5, 1.0].map((lateralM) =>
outletWallAt({ lateralM, slopeM: 0 }),
);
out.inletSlopes = [0.1, 0.5].map((slopeM) => outletWallAt({ lateralM: 0, slopeM }));
// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로
const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6);
function boxFit(edit) {
+53
View File
@@ -0,0 +1,53 @@
"""107 세월교 — 유입 · 유출 따로 · 원지반 제약 삭제 · 유입 날개벽 수평 여유.
화면 TS 를 그 자리에서 돌린 값(`helper_107_ford_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HELPER = Path(__file__).parent / "helper_107_ford_free.cjs"
NODE_MODULES = ROOT / "config" / "node_modules"
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
@pytest.fixture(scope="module")
def out() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_세월교_박스를_고르면_같은_측점_벽_고름을_풂(out: dict) -> None:
assert out["released"] == [173.09, 352.14]
def test_벽을_고르면_세월교_박스_고름을_풂() -> None:
station = _read("B06_Section/B06_Section_UI_Page_Station_Controls.ts")
assert (
"if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);"
in station
)
assert (
"if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);"
in station
)
view = _read("B06_Section/B06_Section_UI_Cross_View.ts")
# 카드 안 — 벽을 고르면 본체 창을 닫고 · 본체를 고르면 벽 창을 닫음
assert "bodies.clearSelection(); // 세월교 · 박스 고름을 풂" in view
assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view
+9
View File
@@ -4,6 +4,7 @@
"""
import json
import math
import subprocess
import sys
from pathlib import Path
@@ -74,3 +75,11 @@ def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None:
assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx(
fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3
)
def test_유입_측벽을_옮겨도_유출_흙막이_자리_그대로(fit: dict) -> None:
"""107-1 — 관 1 m 맞춤은 기본 자리 기준 · 사용자 이동이 반대쪽 흙막이를 안 밈 · L 은 올림."""
backs = {case["back"] for case in fit["inletMoves"] + fit["inletSlopes"]}
assert len(backs) == 1, fit["inletMoves"]
for case in fit["inletMoves"] + fit["inletSlopes"]:
assert case["label"].endswith(f"L{math.ceil(case['lengthM'] - 1e-6)}m")