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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user