From d3540038410c3878d1ff27be9311a5367286a5d1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:59:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B4=80=EB=B3=B4=ED=98=B8?= =?UTF-8?q?=EA=B3=B5=20=EC=97=B0=EB=8F=99=20=ED=92=80=EB=A6=B0=20=EC=98=86?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=EB=B2=BD=200.1=20m=20=EA=B1=B8?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=ED=91=B8=EB=8A=94=20=EC=88=9C=EA=B0=84=20?= =?UTF-8?q?4=EC=B6=95(x=20=C2=B7=20d=20=C2=B7=20h=20=C2=B7=20m)=20?= =?UTF-8?q?=EA=B5=B3=ED=9E=98=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=3D=20=EA=B5=B3=ED=9E=8C=20=EA=B0=92=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20[=EC=97=B0=EB=8F=99]=20=ED=95=9C=20=EA=B8=B8(139-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_UI_Cross_Culvert_Wire.ts | 30 +++-- .../B06_Section_UI_Cross_View_Structure.ts | 22 ++-- .../B06_Section_UI_Page_Link_Session.ts | 60 +++++++++- .../B06_Section_UI_Page_Station_Controls.ts | 16 ++- resources/tester/helper_139_2_link_freeze.cjs | 109 ++++++++++++++++++ resources/tester/test_139_2_link_freeze.py | 55 +++++++++ 6 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 resources/tester/helper_139_2_link_freeze.cjs create mode 100644 resources/tester/test_139_2_link_freeze.py diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 4d1bf1d74..3796e0126 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -409,16 +409,7 @@ export function computeCardCulvert( // 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다. // 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시 // 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자). - const ownerInput = adjustsInput(link.source, revetOffset, extraWalls); - // 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4). - const ownerLayout = computeCulvertLayout( - link.source, - link.source.samples, - ownerInput, - link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), - inletStructure?.adjustFor(link.source), - ); - const ownerApplied = ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; + const ownerApplied = ownerAppliedAdjusts(link.source, revetOffset, inletStructure, extraWalls); // 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄 // 벽만 자기 값으로 선다(2026-08-30 사용자). const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied; @@ -599,6 +590,25 @@ function detachedAdjusts( }; } +/** 소유 측점이 실제로 적용한 4축 — 연동 카드가 물려받는 값 · 연동을 푸는 순간 굳히는 값(139-2). */ +export function ownerAppliedAdjusts( + owner: CrossSection, + revetOffset?: RevetOffsetControl, + inletStructure?: InletStructureControl, + extraWalls?: ExtraWallControl, +): WallAdjustSet { + const ownerInput = adjustsInput(owner, revetOffset, extraWalls); + // 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4). + const ownerLayout = computeCulvertLayout( + owner, + owner.samples, + ownerInput, + owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner), + inletStructure?.adjustFor(owner), + ); + return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput; +} + function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index d88057f65..00fa535c1 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -150,8 +150,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). + // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => - (key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()), + (key !== "inlet" && key !== "outlet") || + (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), isLinkedCard: () => ctx.isLinked, @@ -203,20 +206,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe ctx.isLinked && ctx.drawnWallKeys().length ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } : null, - setLinked: (linked) => { - // 연동을 **푸는 순간** 지금 그려진 높이를 이 측점 값으로 굳힌다(2026-09-06 사용자 - // 지시). 예전에는 위치(4축)만 갈리고 높이는 소유 측점 값을 계속 따라가, 소유 - // 측점 높이를 바꾸면 푼 측점까지 같이 움직였다. 굳혀 두면 값이 튀지 않으면서 - // 이후에는 따로 논다. 단 수·형태는 소유 측점을 그대로 따른다(2026-08-30 확정 유지). - if (!linked) { - ctx.drawnWallKeys().forEach((key) => { - if (ctx.adjustOf(key).h !== null && ctx.adjustOf(key).h !== undefined) return; - const height = ctx.heightOfWall(key); - if (height > 0) ctx.revetOffset?.update(ctx.section.chainage_m, key, { h: height }); - }); - } - ctx.revetLink?.setLinked(ctx.section.chainage_m, linked); - }, + // 푸는 순간 4축(x · d · h · m) 굳힘 · 다시 연동 = 지움 — 제어기 `setLinked` 한 길(139-2 · + // 좌측 관 보호공 [연동]과 같음). 단 수 · 형식은 소유 측점을 그대로 따름(2026-08-30). + setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked), followGrade: () => ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true, setFollowGrade: (follow) => { diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index c12715271..edafcd95a 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -10,12 +10,21 @@ * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; +import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { + computeCardCulvert, + culvertLinkFor, culvertOwnerFor, culvertReach, culvertWallsStandAt, + ownerAppliedAdjusts, + type ExtraWallControl, + type InletStructureControl, type RevetLinkControl, + type RevetOffsetControl, } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure"; import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc"; /** 세션에 담기는 측점별 조작값 한 겹 맵. */ @@ -111,10 +120,59 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag }; } +/** + * 연동을 푸는 순간(카드 [연동] · 좌측 [연동] 둘 다 · 139-2) — 그 카드가 그리던 벽마다 4축을 이 + * 측점 값으로 굳힘: 위치(x · d) = 소유 측점 적용값(물려받던 값) · 높이 · 형태 = 그 카드가 그린 값. + * d 가 차서 이 측점 지형으로 자동 자리 · 관 정수 맞춤을 다시 타지 않음(튐 없음). 연동 중에 부름. + */ +export function freezeLinkedWalls( + section: CrossSection, + sections: readonly CrossSection[], + c: { + revetOffset: RevetOffsetControl; + inletStructure?: InletStructureControl; + extraWalls?: ExtraWallControl; + revetLink: RevetLinkControl; + }, +): void { + const link = section.culvert ? undefined : culvertLinkFor(section, sections, () => null); + const owner = + link && ownerAppliedAdjusts(link.source, c.revetOffset, c.inletStructure, c.extraWalls); + if (!link || !owner) return; + const card = computeCardCulvert( + section, + section.samples, + null, + c.revetOffset, + c.inletStructure, + c.extraWalls, + link, + c.revetLink, + ); + const drawn = card ? culvertCardState(card).wallSpecs : new Map(); + const roles: Array<[RevetKey, WallAdjust]> = [ + ["inlet", owner.inlet], + ["outlet", owner.outlet], + ...owner.extras.map((a, i): [RevetKey, WallAdjust] => [`extra${i}` as RevetKey, a]), + ...owner.basinExtras.map((a, i): [RevetKey, WallAdjust] => [`bextra${i}` as RevetKey, a]), + ]; + for (const [role, applied] of roles) { + const wall = drawn.get(role); + if (!wall) continue; + const round3 = (v: number): number => Math.round(v * 1000) / 1000; + c.revetOffset.update(section.chainage_m, role, { + x: round3(applied.x), + d: applied.d == null ? null : round3(applied.d), + h: wall.height, + m: wall.form, + }); + } +} + /** * 좌측 관 보호공 [연동] · [경사](54-30) — 옛 연동 제어 그대로 읽고 씀. 연동 = 벽이 서는 옆 측점이 * 있을 때만 · 켬 = 그 측점이 모두 연동 · 끄면 모두 따로. 경사 = 보호공 구간이 있을 때만. - * ponytail: 연동을 풀 때 카드 단추가 하던 높이 굳히기는 안 함 — 필요하면 카드 높이 셈을 여기로. + * 풀 때 굳히기 · 다시 걸 때 지우기는 `setLinked` 가 함(139-2 · 카드 단추와 한 길). */ export function pipeRevetChoiceBridge(revetLink: RevetLinkControl) { const linkCards = (owner: CrossSection, sections: readonly CrossSection[]) => diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index fd3fc9e73..79bc25659 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -26,7 +26,11 @@ import type { import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire"; import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls"; -import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session"; +import { + createLinkFlagSession, + createSessionMap, + freezeLinkedWalls, +} from "./B06_Section_UI_Page_Link_Session"; import { createSpanControl } from "./B06_Section_UI_Page_Span_Control"; import type { FordAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; @@ -528,6 +532,16 @@ export function createStationControls(deps: StationControlDeps): StationControls return section.design?.revet_link_detached !== true; }, setLinked: (chainageM, linked) => { + // 푸는 순간 그린 4축을 이 측점 값으로 굳힘 · 다시 연동 = 굳힌 값 지움(139-2) + const section = sectionAt(chainageM); + if (linked) dropLinkedAdjusts(chainageM); + else if (section && revetLinkControl.linkedFor(section)) + freezeLinkedWalls(section, deps.detail()?.cross_sections ?? [], { + revetOffset: revetOffsetControl, + inletStructure: inletStructureControl, + extraWalls: extraWallControl, + revetLink: revetLinkControl, + }); linkDetached.set(chainageKey(chainageM), !linked); persistLinkFlags(); patchCachedDesign(chainageM, { revet_link_detached: !linked }); diff --git a/resources/tester/helper_139_2_link_freeze.cjs b/resources/tester/helper_139_2_link_freeze.cjs new file mode 100644 index 000000000..bb5657f8f --- /dev/null +++ b/resources/tester/helper_139_2_link_freeze.cjs @@ -0,0 +1,109 @@ +/* 139-2 연동 풀림 굳힘 헬퍼 — 화면 TS(`_Page_Link_Session.freezeLinkedWalls` · `_Cross_Culvert_Wire`)를 + * 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 73-4 픽스처(박스 352.14 흙막이 · 옆 측점 340 · 360). + * 짝 = `test_139_2_link_freeze.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 noop = new Proxy({}, { get: () => () => "" }); +// 조정창 모듈이 불러올 때 문서 이벤트를 검 — 기하엔 안 쓰임 +globalThis.document = { addEventListener: () => undefined }; +globalThis.window = globalThis; + +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 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + // 화면 전용(토스트 · 글 · 설정) — 기하엔 안 쓰임 + if (request.startsWith("@ui/") || request.startsWith("@config/")) return noop; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + 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`].find((p) => fs.existsSync(p) && fs.statSync(p).isFile()) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { freezeLinkedWalls } = loadTs(B06("B06_Section_UI_Page_Link_Session.ts")); +const { computeCardCulvert, culvertLinkFor } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire.ts")); + +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +).cross_sections; +const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6); + +// 제어기 흉내 — 측점:역할 → 4축(세션 맵과 같은 꼴) +const stored = new Map(); +const keyOf = (c, role) => `${c.toFixed(2)}:${role}`; +const ZERO = { x: 0, d: null, h: null, m: null }; +const revetOffset = { + adjustFor: (s, role) => ({ ...ZERO, ...(stored.get(keyOf(s.chainage_m, role)) ?? {}) }), + storedAdjustFor: (s, role) => stored.get(keyOf(s.chainage_m, role)) ?? null, + update: (c, role, patch) => + stored.set(keyOf(c, role), { ...ZERO, ...(stored.get(keyOf(c, role)) ?? {}), ...patch }), + reset: (c, role) => stored.delete(keyOf(c, role)), + syncApplied: () => undefined, + selectedFor: () => null, + highlightFor: () => null, + select: () => undefined, +}; +const extraWalls = { countFor: () => 0, consumeEqualize: () => false }; +let linked = true; +const revetLink = { linkedFor: () => linked, followGradeFor: () => true }; +const controls = { revetOffset, extraWalls, revetLink }; + +const round = (v) => Math.round(v * 1000) / 1000; +const shape = (s) => { + const link = culvertLinkFor(s, sections, () => null); + const layout = computeCardCulvert( + s, + s.samples, + null, + revetOffset, + undefined, + extraWalls, + link, + revetLink, + ); + return (layout?.walls ?? []).map((w) => ({ + role: w.role, + points: w.points.map((p) => [round(p.offset), round(p.elevation)]), + })); +}; + +const owner = at(352.14); +const card = at(360); +// 소유 측점 벽을 조금 옮겨 둠 — 옆 카드가 물려받는 자리가 자동 자리가 아니게 +revetOffset.update(owner.chainage_m, "outlet", { x: 0.7, h: 2.3 }); +const linkedShape = shape(card); +freezeLinkedWalls(card, sections, controls); +const frozen = Object.fromEntries( + [...stored].filter(([k]) => k.startsWith("360.00:")).map(([k, v]) => [k.slice(7), v]), +); +linked = false; +const detachedShape = shape(card); +// 푼 뒤 소유 측점을 또 옮김 — 푼 카드는 안 따라감 +revetOffset.update(owner.chainage_m, "outlet", { x: 2.0 }); +const afterOwnerMove = shape(card); +linked = true; // 대조 — 연동이면 소유 측점 이동을 따라감(시험이 실제로 재는지) +const linkedAfterMove = shape(card); +process.stdout.write( + JSON.stringify({ linkedShape, frozen, detachedShape, afterOwnerMove, linkedAfterMove }), +); diff --git a/resources/tester/test_139_2_link_freeze.py b/resources/tester/test_139_2_link_freeze.py new file mode 100644 index 000000000..c8c6f66d2 --- /dev/null +++ b/resources/tester/test_139_2_link_freeze.py @@ -0,0 +1,55 @@ +"""139-2 관보호공 연동 풀림 — 옆 측점 벽 0.1 m 걸음 · 푸는 순간 4축 굳힘 · 다시 연동 = 지움. + +화면 TS 를 그 자리에서 돌린 값(`helper_139_2_link_freeze.cjs` · 73-4 픽스처 박스 352.14 흙막이 · 옆 측점 360). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_139_2_link_freeze.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 test_푸는_순간_4축이_차고_그림은_그대로(out: dict) -> None: + assert set(out["frozen"]) == {"inlet", "outlet"} + for adjust in out["frozen"].values(): + assert isinstance(adjust["x"], (int, float)) + assert isinstance(adjust["d"], (int, float)) # d 가 차서 자동 자리 · 정수 맞춤 안 탐 + assert adjust["h"] > 0 and adjust["m"] + assert out["frozen"]["outlet"]["h"] == pytest.approx(2.3) # 소유 측점 높이 그대로 + assert out["detachedShape"] == out["linkedShape"] # 튐 없음 + + +def test_푼_뒤엔_소유_측점_이동을_안_따라감(out: dict) -> None: + assert out["afterOwnerMove"] == out["detachedShape"] + assert out["linkedAfterMove"] != out["linkedShape"] # 대조 — 연동이면 따라감 + + +def test_한_길_걸음_다시_연동_지움() -> None: + stc = (ROOT / "B06_Section" / "B06_Section_UI_Page_Station_Controls.ts").read_text( + encoding="utf-8" + ) + vs = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View_Structure.ts").read_text( + encoding="utf-8" + ) + # 카드 [연동] · 좌측 [연동] 모두 제어기 setLinked 로 — 풀면 굳힘 · 걸면 지움 + assert "if (linked) dropLinkedAdjusts(chainageM);" in stc + assert "freezeLinkedWalls(section" in stc + assert "setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked)" in vs + # 풀린 옆 측점 카드 = 0.1 m 걸음 + assert "(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false)" in vs