diff --git a/A00_Common/design/design_pipeline_cross.ts b/A00_Common/design/design_pipeline_cross.ts index 866b66758..bf08af578 100644 --- a/A00_Common/design/design_pipeline_cross.ts +++ b/A00_Common/design/design_pipeline_cross.ts @@ -9,7 +9,7 @@ * 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 * 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단 * 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 · - * 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음. + * 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음. * ========================================================================== */ import { readState } from "../b_page_state"; @@ -75,7 +75,7 @@ function carried(section: CrossSection): Record { /** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */ function sessionCells( - name: "crossdesign" | "rockb" | "cutslope", + name: "crossdesign" | "rockb" | "cutslope" | "bermdepth", projectId: string, routeId: number, ): Array<[string, unknown]> { @@ -102,6 +102,12 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] const ratio = finite(value); if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null; } + // 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) + for (const [key, value] of sessionCells("bermdepth", projectId, routeId)) { + const sides = (value ?? {}) as Record; + if (finite(sides.left) !== null) (out[key] ??= {}).bermDepthLeftM = sides.left; + if (finite(sides.right) !== null) (out[key] ??= {}).bermDepthRightM = sides.right; + } return out; } diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 69f6a0bcc..060cc3e40 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -14,8 +14,7 @@ * (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점 * 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) · * 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분. - * 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 → - * 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄). + * 고리(365-2) — 열 때 · 들어올 때 입력 대 묶음 입력 · 다르면 한 바퀴 · 최대 3 · 넘으면 `onUnstable`. * 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 · * 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림. * 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선). @@ -87,8 +86,10 @@ import { routeRange, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade"; import { + crossWithBerm, crossWithUphill, draftSectionDetail, + type BermAt, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; @@ -148,12 +149,13 @@ export interface PagePipelineOptions { ) => (stations: PipelineStation[]) => Promise; /** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */ onUnstable?: () => void; - /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null - * (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */ + /** 소단 구간(지금 구조물 목록 → 누가거리마다 제원) — 초안 cross 가 따라간 구조물로 소단을 셈(365-5). */ + bermAt?: (structures: unknown) => BermAt; + /** 저장 노선 cross = 다시 셈 창구 한 벌(365-4 · 측점이 상세와 다르면 null = 엔진) · 주면 종단도는 따로 안 셈. */ savedCross?: ( - detail: SectionDetailResponse, + d: SectionDetailResponse, routeId: number, - stations: ReadonlyArray, + s: PipelineStation[], ) => Promise; } @@ -362,26 +364,27 @@ function createPagePipeline( ground: createGroundStage(groundDeps), planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }), profile: profileStage, - // 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈 - // 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4) + // 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은 + // 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침) cross: (ctx) => { - // 엔진은 다시 셈 창구가 낸 앞 출력을 이어 쓰지 않음 — 초안 = 새로고침과 같은 값(365-4) + const stations = ctx.out.ground?.stations ?? []; const engine = () => crossStage.stage({ ...ctx, prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev, inputs: { ...ctx.inputs, - cross: crossWithUphill( - ctx.inputs.cross as CrossInput | null, - ctx.out.ground?.stations ?? [], + cross: crossWithBerm( + crossWithUphill(ctx.inputs.cross as CrossInput | null, stations), + stations, + options.bermAt?.(ctx.inputs.structures) ?? null, ), }, }); const { detail = null, routeId } = savedSource(); const saved = !ctx.inputs.route && detail && typeof routeId === "number" - ? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? []) + ? options.savedCross?.(detail, routeId, stations) : null; if (!saved) return engine(); return saved.then((out) => { diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 3d87fd8c8..41e1b0711 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -41,6 +41,7 @@ import { swapSectionStations, } from "../../B06_Section/B06_Section_Section_Store"; import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh"; +import { bermSpansFromStructures, bermSpecAt } from "../../B06_Section/B06_Section_UI_Page_Persist"; import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; function L(key: keyof typeof ui_locales): string { @@ -212,6 +213,11 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()), onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)), onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"), + // 초안 소단 = 지금 구조물(따라간 자리)의 소단 구간(365-5 · 저장 노선은 다시 셈 창구가 세션 사본으로) + bermAt: (structures) => { + const spans = bermSpansFromStructures(Array.isArray(structures) ? structures : []); + return (chainageM) => bermSpecAt(spans, chainageM); + }, // 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을 // 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진 savedCross: async (detail, routeId, stations) => { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 22c3f6b8e..4f4bc3896 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -24,6 +24,9 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu"; /** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */ const CLICK_SLOP_PX = 4; +/** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */ +const HTML_CONTROLS = + "button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil"; export interface DrainageInteractParams { viewport: HTMLElement; @@ -92,6 +95,9 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { viewport.addEventListener("pointerdown", (event) => { if (contextMenu.contains(event.target)) return; contextMenu.close(); + // 지도 위 HTML 조작 요소(흐림 판 [업데이트] 등) 누름은 그 요소 몫 — 아래 관 표지를 집어 + // setPointerCapture 하면 단추가 pointerup · click 을 못 받는다(369-3). + if (event.target instanceof Element && event.target.closest(HTML_CONTROLS)) return; // 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); const rect = viewport.getBoundingClientRect(); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 9ddf421bf..e4485d41e 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -80,6 +80,38 @@ export function crossWithUphill( return { ...input, byKey }; } +/** 소단 한 측점 제원(세션 `berm` 꼴 · 구조물 목록에서 폄). */ +export type BermAt = ( + chainageM: number, +) => { width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | null; + +/** 측점마다 소단 = 지금 구조물(초안 따라간 자리)의 소단 구간 · 쪽 깊이는 세션 고침(`bermDepthLeftM` · + * `bermDepthRightM` · 측점 열쇠) — 노선 초안 동안 소단 고침도 묶음으로(365-5). 구간 밖 측점은 소단 없음. */ +export function crossWithBerm( + input: CrossInput | null, + stations: ReadonlyArray, + bermAt: BermAt | null, +): CrossInput | null { + if (!input || !bermAt) return input; + const byKey: CrossInput["byKey"] = { ...input.byKey }; + for (const station of stations) { + const { bermDepthLeftM, bermDepthRightM, ...rest } = input.byKey[station.key] ?? {}; + const at = bermAt(station.chainageM); + byKey[station.key] = { + ...rest, + berm: at && { + widthM: at.width_m, + intervalM: at.interval_m, + slopeDeg: at.slope_deg, + depthM: at.depth_m, + depthLeftM: (bermDepthLeftM as number | undefined) ?? null, + depthRightM: (bermDepthRightM as number | undefined) ?? null, + }, + }; + } + return { ...input, byKey }; +} + const kindOf = (station: PipelineStation): SectionStation["kind"] => station.key === "end:BP" ? "bp" diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index eb9f29bfa..8e3c124b4 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -418,6 +418,55 @@ async function run() { .map((r) => ({ outcome: r.outcome, cross: "cross" in r.stages, sources: r.sources })); pageMod.disposePagePipeline(Q); page.clearDrafts(Q); + + // ⑪ 초안 소단(365-5) — 소단 = 지금 구조물 구간(bermAt) · 쪽 깊이 = 세션 고침(측점 열쇠) → 묶음 cross + const B = "proj-365c"; + window.localStorage.setItem("frd_current_project_id", B); + const span = { from: 30, to: 70 }; + const bBridge = pageMod.routeDraftBridge(B, link(), { + stationIntervalM: job.interval, + bermAt: () => (c) => + c >= span.from && c <= span.to + ? { width_m: 1.5, interval_m: 5, slope_deg: 60, depth_m: 1 } + : null, + }); + pageMod.pipelineEarthwork(B, { conversion: job.conversion }); + const bPipe = pageMod.pagePipeline(B); + await settle(runs().length + 1); + bBridge.edited(draftOf(shift(n - 2, 0, 2))); + await settle(runs().length + 1); + await tick(20); + const bermRows = () => + bPipe.output("ground").stations.map((st) => { + const d = bPipe.output("cross").byKey[st.key]?.detail ?? {}; + return [ + st.key, + r3(st.chainageM), + d.berm ? r3(d.berm.width_m) : null, + d.berm_depth_left_m ?? null, + ]; + }); + const bermBefore = bermRows(); + const inside = bermBefore.find((row) => row[1] > span.from + 1 && row[1] < span.to - 1); + page.writeState("bermdepth", { [inside[0]]: { left: 2.5 } }, B, 7); + mark = runs().length; + pageMod.pipelineCross(B); + await settle(mark + 1); + await tick(20); + const bermAfter = bermRows(); + const berm = { + inSpan: bermBefore + .filter((row) => row[1] >= span.from && row[1] <= span.to) + .every((row) => row[2] === 1.5), + outSpan: bermBefore + .filter((row) => row[1] < span.from || row[1] > span.to) + .every((row) => row[2] === null), + depthBefore: inside[3], + depthAfter: bermAfter.find((row) => row[0] === inside[0])[3], + runs: runs().length - mark, + }; + pageMod.disposePagePipeline(B); + page.clearDrafts(B); return { kept, b05, @@ -429,6 +478,7 @@ async function run() { saved, shiftRuns, sameAfterReload, + berm, }; } diff --git a/resources/tester/helper_369_3_update_press.cjs b/resources/tester/helper_369_3_update_press.cjs new file mode 100644 index 000000000..41ecd61ea --- /dev/null +++ b/resources/tester/helper_369_3_update_press.cjs @@ -0,0 +1,147 @@ +/* 369-3 [업데이트] 진짜 누름 헬퍼 — `B05_Profile_UI_Drainage_Interact.ts` 를 그 자리에서 + * 트랜스파일해 가짜 viewport · 단추 · 관 표지로 pointerdown → pointerup → click 차례를 돌림. + * 사로잡기(setPointerCapture)가 걸리면 pointerup · click 은 viewport 로 감(브라우저와 같음). + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +class Element { + constructor(tag, parent = null) { + this.tag = tag; + this.parent = parent; + this.className = ""; + this.listeners = {}; + } + matches(selector) { + return selector.split(",").some((part) => { + const s = part.trim(); + return s.startsWith(".") ? this.className.split(" ").includes(s.slice(1)) : s === this.tag; + }); + } + closest(selector) { + for (let node = this; node; node = node.parent) if (node.matches(selector)) return node; + return null; + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + fire(type, event) { + (this.listeners[type] ?? []).forEach((fn) => fn(event)); + } +} +global.Element = Element; + +const stubs = { + "../A00_Common/touch_bridge": { wheelZoomsIn: () => true }, + "../B04_PreProcess/B04_PreProcess_UI_MapRender": { + computeMaxScale: () => 10, + lonLatToScreen: () => ({ x: 0, y: 0 }), + }, + "../B04_PreProcess/B04_PreProcess_UI_MapOverlays": { pointInRings: () => true }, + "./B05_Profile_UI_Drainage_ZoomRange": { clampWheelScale: (value) => value }, +}; +const file = path.join(B05, "B05_Profile_UI_Drainage_Interact.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)( + box.exports, + box, + (name) => stubs[name] ?? require(name), +); + +function setup() { + const viewport = new Element("div"); + viewport.className = "b05-drainage__viewport"; + viewport.style = { removeProperty() {} }; + viewport.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 300 }); + viewport.captured = null; + viewport.setPointerCapture = (id) => (viewport.captured = id); + const canvas = new Element("canvas", viewport); + const veil = new Element("div", viewport); + veil.className = "b05-drainage__veil"; + const update = new Element("button", veil); + update.className = "b05-drainage__analyze b05-drainage__update"; + const log = { live: 0, grabs: 0, picks: 0 }; + update.addEventListener("click", () => log.live++); // [업데이트] = drainage/live 한 번 + let dragging = false; + const state = { scale: 1, offset: { x: 0, y: 0 } }; + box.exports.bindDrainageInteractions({ + viewport, + contextMenu: { contains: () => false, close() {} }, + // 가짜 표지 — 단추 바로 아래(어디를 눌러도 집힘) + pipeEditor: { + handleDown: () => ((dragging = true), log.grabs++, true), + handleMove: () => dragging, + handleUp: () => ((dragging = false), false), + }, + currentView: () => ({ mapRect: { width: 400 }, width: 400 }), + getScale: () => state.scale, + setScale: (v) => (state.scale = v), + getMeta: () => null, + getMinScale: () => 0.1, + getOffset: () => state.offset, + setOffset: (x, y) => (state.offset = { x, y }), + scheduleDraw() {}, + getNormalizer: () => ({}), + getBasins: () => [ + { + index: 0, + polygon_lonlat: [ + [0, 0], + [1, 0], + [1, 1], + ], + area_m2: 1, + }, + ], + getSelectedBasin: () => null, + selectBasin: () => log.picks++, + }); + return { viewport, canvas, update, log, state }; +} + +/** 진짜 마우스 누름 — 사건은 target 에서 viewport 로 거품 · 사로잡히면 up · click 이 viewport 로. */ +function press(world, target, button = 0) { + const event = { target, button, pointerId: 1, pointerType: "mouse", clientX: 200, clientY: 150 }; + const bubble = (type, from) => { + for (let node = from; node; node = node.parent) + node.fire(type, { ...event, preventDefault() {} }); + }; + world.viewport.captured = null; + bubble("pointerdown", target); + const upTarget = world.viewport.captured !== null ? world.viewport : target; + bubble("pointerup", upTarget); + bubble("click", upTarget); +} + +const onButton = setup(); +press(onButton, onButton.update); +const onMap = setup(); +press(onMap, onMap.canvas); +const panMap = setup(); +const panEvent = { + target: panMap.canvas, + button: 1, + pointerId: 2, + pointerType: "mouse", + preventDefault() {}, +}; +panMap.viewport.fire("pointerdown", { ...panEvent, clientX: 0, clientY: 0 }); +panMap.viewport.fire("pointermove", { ...panEvent, clientX: 30, clientY: 40 }); + +process.stdout.write( + JSON.stringify({ + button: { + ...onButton.log, + captured: onButton.viewport.captured, + offset: onButton.state.offset, + }, + map: { ...onMap.log, captured: onMap.viewport.captured }, + pan: { captured: panMap.viewport.captured, offset: panMap.state.offset }, + }), +); diff --git a/resources/tester/test_365_page_pipeline.py b/resources/tester/test_365_page_pipeline.py index fbc1c2841..ac232a923 100644 --- a/resources/tester/test_365_page_pipeline.py +++ b/resources/tester/test_365_page_pipeline.py @@ -155,6 +155,12 @@ def test_365_4_창구로_연_뒤_초안_새로고침과_같음(r): assert s["rows"] > 5 and s["marked"] == 0 and s["same"] +def test_365_5_초안_소단_고침(r): + b = r["berm"] + assert b["inSpan"] and b["outSpan"] # 소단 = 지금 구조물 구간(초안 측점) + assert b["depthBefore"] is None and b["depthAfter"] == 2.5 and b["runs"] == 1 + + def test_365_4_다시_그림_한_번(): swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8") view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_369_3_update_press.py b/resources/tester/test_369_3_update_press.py new file mode 100644 index 000000000..28bc93455 --- /dev/null +++ b/resources/tester/test_369_3_update_press.py @@ -0,0 +1,42 @@ +"""369-3 배수유역 흐림 판 [업데이트] 진짜 누름 — 아래 관 표지가 있어도 단추가 click 을 받음. + +- 단추 위 pointerdown = viewport 집기 · 사로잡기 · 유역 고르기 안 함 → click = 계산 시작 1회. +- 지도(캔버스) 위 좌클릭 = 관 표지 집기 그대로 · 가운데 버튼 = 팬 그대로. +Node 헬퍼(`helper_369_3_update_press.cjs`)가 Interact.ts 를 그 자리에서 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_369_3_update_press.cjs")], + capture_output=True, + text=True, + timeout=60, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_단추_누름은_계산_시작_한_번(r): + assert r["button"] == { + "live": 1, + "grabs": 0, + "picks": 0, + "captured": None, + "offset": {"x": 0, "y": 0}, + } + + +def test_지도_누름은_관_표지_집기_가운데는_팬(r): + assert r["map"] == {"live": 0, "grabs": 1, "picks": 0, "captured": 1} + assert r["pan"] == {"captured": 2, "offset": {"x": 30, "y": 40}}