diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index b3fdefb2b..88d0db750 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -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; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index d815290ac..1d59b2136 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 55bc589db..c6f8a34f7 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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); } diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 45faf329c..bf422f71b 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -109,6 +109,8 @@ export interface FordControlDeps { queuePipeOptions: (chainageM: number, patch: Record) => 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); }, }; diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index d8c1f1b7e..c75854fb8 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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, ); diff --git a/resources/tester/helper_107_ford_free.cjs b/resources/tester/helper_107_ford_free.cjs new file mode 100644 index 000000000..59aef59ac --- /dev/null +++ b/resources/tester/helper_107_ford_free.cjs @@ -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)); diff --git a/resources/tester/helper_80_ford_pipe.cjs b/resources/tester/helper_80_ford_pipe.cjs index 4957556cc..5e8833494 100644 --- a/resources/tester/helper_80_ford_pipe.cjs +++ b/resources/tester/helper_80_ford_pipe.cjs @@ -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) { diff --git a/resources/tester/test_107_ford_free.py b/resources/tester/test_107_ford_free.py new file mode 100644 index 000000000..9fb762f2a --- /dev/null +++ b/resources/tester/test_107_ford_free.py @@ -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 diff --git a/resources/tester/test_80_ford_pipe.py b/resources/tester/test_80_ford_pipe.py index f101a302f..c3c208fca 100644 --- a/resources/tester/test_80_ford_pipe.py +++ b/resources/tester/test_80_ford_pipe.py @@ -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")