diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index f6f4e1918..e0709d413 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -397,6 +397,8 @@ export function createRouteMarkers( sprite.position.copy(position); sprite.position.y += 3.4; sprite.renderOrder = 10; + // 라벨은 읽기용 — 띠 가운데 위에 떠 누름을 가로채지 않음(232-1) + sprite.raycast = () => undefined; return sprite; } @@ -477,6 +479,8 @@ export function createRouteMarkers( head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. head.position.y = PIN_HEIGHT + 1.7; group.add(beam, head); + // 핀은 표시만 — 누름을 가로채지 않음(띠 가운데 누름이 핀에 먼저 맞았음 · 232-1) + beam.raycast = head.raycast = () => undefined; group.renderOrder = 9; group.visible = false; interactionGroup.add(group); @@ -520,7 +524,9 @@ export function createRouteMarkers( deleteSelected, selectObject(object: THREE.Object3D | undefined) { if (typeof object?.userData.stationId === "string") { - selectStation(object.userData.stationId); + // 같은 띠 다시 누름 = 풂(232-1) + const id = object.userData.stationId as string; + selectStation(id === selectedStationId ? null : id); selectionListener?.(null); return; } diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index 84ce34729..631e51bff 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -58,6 +58,7 @@ export function wireStructurePick( coverable?: (entry: StructureInstance) => boolean, ): void { controls.onPick = (pick: StructurePick | null): void => { + // null = 같은 부재 다시 누름(풂) — 빈 곳 누름은 여기까지 안 옴(232-1) if (!pick) { selectDesign(projectId, NO_SELECTION, { from: "b05" }); selectStation(null); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index fde2c5705..bd13562a0 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer { camera: cameraRig.camera, group: () => corridor.group(), blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. diff --git a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts index afeb0ab9b..7ab032e7e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts @@ -24,6 +24,8 @@ export interface MarkerPointerControls { beginMoveSelected: () => void; /** 마커를 잡고 있거나 이동 대기 중인가 — 회전 중심 유틸이 회전을 넘길 판단에 쓴다. */ blocked: () => boolean; + /** 마지막 누름이 마커 · 측점 띠에 맞았나 — 3D 구조물 고름이 그 누름을 「빈 곳」으로 읽지 않게(232-1). */ + pressedMarker: () => boolean; /** 등록한 캔버스 이벤트를 모두 떼어낸다. */ dispose: () => void; } @@ -43,6 +45,7 @@ export function bindMarkerPointerControls(options: { let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; let draggingMarker = false; let lastDragPoint: { x: number; y: number; z: number } | null = null; + let downOnMarker = false; /** 화면 좌표 → 정규화 장치 좌표(-1~1). */ function pointerOf(clientX: number, clientY: number): THREE.Vector2 { @@ -86,7 +89,14 @@ export function bindMarkerPointerControls(options: { function markerHit(event: PointerEvent): THREE.Object3D | undefined { const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointerOf(event.clientX, event.clientY), camera()); - return raycaster.intersectObject(markers.group, true)[0]?.object; + // 숨은 것(꺼 둔 측점선 · 라벨)은 누름을 가로채지 않음 — three 레이캐스트는 visible 을 안 봄(232-1) + return raycaster.intersectObject(markers.group, true).find((entry) => shown(entry.object)) + ?.object; + } + + function shown(object: THREE.Object3D | null): boolean { + for (let node = object; node; node = node.parent) if (!node.visible) return false; + return true; } function finishMarkerInteraction(selectCandidate: boolean): void { @@ -105,6 +115,7 @@ export function bindMarkerPointerControls(options: { function handlePointerDown(event: PointerEvent): void { if (event.button !== 0) return; const hit = markerHit(event); + downOnMarker = !!hit; const pointId = markers.pointIdForObject(hit); if (pointId) { dragCandidate = { @@ -184,6 +195,7 @@ export function bindMarkerPointerControls(options: { movingSelected = true; }, blocked: () => dragCandidate !== null || draggingMarker || movingSelected, + pressedMarker: () => downOnMarker, dispose: () => { canvas.removeEventListener("dragover", handleDragOver); canvas.removeEventListener("drop", handleDrop); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts index 1fab0d9d3..b48cd5216 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -20,7 +20,7 @@ export interface StructurePick { } export interface StructurePickControls { - /** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */ + /** 3D에서 골랐을 때 알린다. null = 같은 부재 다시 누름으로 해제(빈 곳 누름은 안 알림 · 232-1). */ onPick?: (pick: StructurePick | null) => void; /** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */ selectAtChainage: (chainageM: number | null) => void; @@ -47,8 +47,10 @@ export function bindStructurePick(options: { group: () => THREE.Object3D | null; /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ blocked: () => boolean; + /** 이 누름이 마커 · 측점 띠에 맞았나 — 참이면 그 누름은 마커 몫(빈 곳 누름 아님 · 232-1). */ + pressedMarker?: () => boolean; }): StructurePickControls { - const { canvas, camera, group, blocked } = options; + const { canvas, camera, group, blocked, pressedMarker } = options; let selected: StructurePick | null = null; let down: { x: number; y: number; pointerId: number } | null = null; @@ -94,7 +96,7 @@ export function bindStructurePick(options: { }); } - /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */ + /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=빈 곳)이다. */ function pickAt(clientX: number, clientY: number): StructurePick | null { const root = group(); if (!root || !root.visible) return null; @@ -120,7 +122,7 @@ export function bindStructurePick(options: { function handleDown(event: PointerEvent): void { down = - event.button === 0 && !blockedBeforeDown && !blocked() + event.button === 0 && !blockedBeforeDown && !blocked() && !pressedMarker?.() ? { x: event.clientX, y: event.clientY, pointerId: event.pointerId } : null; } @@ -131,7 +133,14 @@ export function bindStructurePick(options: { if (!start || event.pointerId !== start.pointerId) return; if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > CLICK_SLOP_PX) return; if (blocked()) return; - selected = pickAt(event.clientX, event.clientY); + const pick = pickAt(event.clientX, event.clientY); + // 빈 곳(지형 · 리본) 누름은 고름을 안 풂(08-05 사용자 결정 · 232-1) — 같은 부재 다시 누름만 풂 + if (!pick) return; + const same = + selected !== null && + Math.abs(selected.chainageM - pick.chainageM) < 0.01 && + selected.key === pick.key; + selected = same ? null : pick; applyHighlight(); controls.onPick?.(selected); } diff --git a/resources/tester/helper_232_1_3d_press.cjs b/resources/tester/helper_232_1_3d_press.cjs new file mode 100644 index 000000000..26ebb5991 --- /dev/null +++ b/resources/tester/helper_232_1_3d_press.cjs @@ -0,0 +1,213 @@ +/* 232-1 3D 누름 길 통째 — 진짜 마커(MK3) · 마커 입력(MI) · 3D 부재 고름(SPK) · 선택 동기화(SY) · + * 넘김(PKS) · 고름 한 칸(DSel) 을 three + 가짜 캔버스로 한 누름(창 캡처 → 캔버스 듣개 차례)씩 돌림. + * 배선은 `B05_Profile_UI_Viewer.ts` · `design_view_profile.ts` 와 같은 꼴. 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const NM = path.join(ROOT, "config", "node_modules"); +const ts = require(path.join(NM, "typescript")); +const THREE = require(path.join(NM, "three", "build", "three.cjs")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (request === "three") return THREE; + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/* ── 창 · 캔버스 흉내(듣개 차례 = 창 캡처 → 캔버스 등록 차례) ── */ +const windowListeners = []; +globalThis.window = { + addEventListener: (type, fn) => windowListeners.push({ type, fn }), + removeEventListener: (type, fn) => { + const i = windowListeners.findIndex((entry) => entry.type === type && entry.fn === fn); + if (i >= 0) windowListeners.splice(i, 1); + }, +}; +globalThis.document = { createElement: () => ({ getContext: () => null, width: 0, height: 0 }) }; +const canvasListeners = []; +const canvas = { + addEventListener: (type, fn) => canvasListeners.push({ type, fn }), + removeEventListener() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 400 }), + setPointerCapture() {}, + hasPointerCapture: () => false, + releasePointerCapture() {}, +}; +function fire(type, clientX, clientY) { + const event = { + type, + button: 0, + pointerId: 1, + clientX, + clientY, + preventDefault() {}, + stopPropagation() {}, + }; + for (const entry of windowListeners.filter((e) => e.type === type)) entry.fn(event); + for (const entry of canvasListeners.filter((e) => e.type === type)) entry.fn(event); +} + +/* ── 진짜 모듈 ── */ +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const MK3 = loadTs("B05_Profile/B05_Profile_UI_Markers.ts", { + "@util/common_util_svg": { + inferStationInterval: () => 20, + stationLabel: (chainage) => `0+${chainage}`, + }, +}); +const MI = loadTs("B05_Profile/B05_Profile_UI_Viewer_Marker_Input.ts", { + "./B05_Profile_UI_Markers": MK3, +}); +const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts"); +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": { + irregularStationId: (id) => `irregular:${id}`, + isPipeStation: (s) => s.origin === "pipe", + }, +}); +const PKS = loadTs("B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts", { + "../A00_Common/design/design_selection": DSel, +}); + +/* ── 장면: 지형 범위 · 위에서 내려다보는 직교 카메라 ── */ +const PID = "p232"; +const bounds = { x: [0, 200], y: [0, 200], z: [0, 10] }; +const scene = new THREE.Scene(); +const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 500); +camera.position.set(0, 200, 0); +camera.up.set(0, 0, -1); +camera.lookAt(0, 0, 0); +camera.updateProjectionMatrix(); +camera.updateMatrixWorld(true); +const clientOf = (x, y, z = 5) => { + const p = MK3.modelToScene({ x, y, z }, bounds).project(camera); + return [((p.x + 1) / 2) * 400, ((1 - p.y) / 2) * 400]; +}; + +const markers = MK3.createRouteMarkers(scene, () => bounds); +const STATIONS = [100, 120].map((at) => ({ + station_id: `s${at}`, + chainage_m: at, + center_x: at, + center_y: 100, + center_z: 5, + frame: { left_xy: [0, 1] }, + kind: "regular", +})); +markers.renderStationLines(STATIONS, 3); + +// 코리도 — 리본(신원표 없음 · 「빈 곳」) + 부재 하나(측점 160 · inlet) +const corridor = new THREE.Group(); +const ribbon = new THREE.Mesh( + new THREE.BoxGeometry(400, 0.1, 400), + new THREE.MeshLambertMaterial(), +); +ribbon.position.y = -0.5; +const member = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 4), new THREE.MeshLambertMaterial()); +member.position.copy(MK3.modelToScene({ x: 160, y: 160, z: 6 }, bounds)); +member.userData = { chainageM: 160, kind: "pipe", key: "inlet" }; +corridor.add(ribbon, member); +scene.add(corridor); +scene.updateMatrixWorld(true); + +const markerInput = MI.bindMarkerPointerControls({ + canvas, + camera: () => camera, + controls: { enabled: true }, + markers, + getTerrain: () => null, + getBounds: () => bounds, +}); +const structurePick = SPK.bindStructurePick({ + canvas, + camera: () => camera, + group: () => corridor, + blocked: () => markerInput.blocked(), + pressedMarker: () => markerInput.pressedMarker(), +}); + +/* ── 종단 보기 배선 흉내(design_view_profile) ── */ +let graph = null; +let form = null; +let syncing = false; +const sync = SY.createSelectionSync({ + stations: () => [], + isSyncing: () => syncing, + setSyncing: (v) => (syncing = v), + selectMarker: (id) => markers.selectStation(id), + selectGraph: (id) => (graph = id), + selectSidebar: (at) => (form = at), + openAtStation: (at) => (form = at), + selectBasin() {}, + markStation() {}, + selectStructure3D: (at) => structurePick.selectAtChainage(at), + regularChainage: (id) => STATIONS.find((s) => s.station_id === id)?.chainage_m, + remember: (at) => + DSel.selectDesign(PID, at === null ? DSel.NO_SELECTION : { chainageM: at, structureId: null }), +}); +markers.onStationSelectionChange((id) => { + graph = id; + sync.syncIrregularSelection(id); +}); +PKS.wireStructurePick( + structurePick, + PID, + { + resolveAt: () => ({ structureId: null }), + hasSelection: () => form !== null, + selectAt: (sel) => (form = sel.chainageM), + }, + sync.selectStationOfPipe, +); + +/* ── 읽기 ── */ +const bar = (id) => markers.group.children[1].children.find((o) => o.userData.stationId === id); +const pin = () => markers.group.children.find((g) => g.renderOrder === 9); +const state = () => ({ + s100: "#" + bar("s100").material.color.getHexString(), + s120: "#" + bar("s120").material.color.getHexString(), + pin: pin().visible, + session: DSel.readSelection(PID), + graph, + member: member.material.emissive.getHex() !== 0, +}); +const press = (xy) => { + fire("pointerdown", ...xy); + const down = state(); + fire("pointerup", ...xy); + return { down, up: state() }; +}; + +const out = {}; +const edge120 = clientOf(120, 102); // 띠 끝쪽(가운데 핀 · 라벨 자리 밖) +const center120 = clientOf(120, 100); +const empty = clientOf(40, 40); // 리본만 +out.bar = press(edge120); +out.empty = press(empty); +out.again = press(edge120); // 같은 띠 다시 = 풂 +out.center = press(center120); // 숨은 핀 · 라벨이 있던 가운데 +out.memberPick = press(clientOf(160, 160)); +out.memberEmpty = press(empty); +out.memberAgain = press(clientOf(160, 160)); // 같은 부재 다시 = 풂 +press(edge120); +markers.setStationLinesVisible(false); +out.hiddenLines = press(clientOf(100, 102)); // 꺼 둔 측점선은 누름을 안 가로챔 +out.hiddenPressedMarker = markerInput.pressedMarker(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_232_1_3d_press.py b/resources/tester/test_232_1_3d_press.py new file mode 100644 index 000000000..ea08494ef --- /dev/null +++ b/resources/tester/test_232_1_3d_press.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""232-1 3D 띠 강조 바로 꺼짐(요구 104 · 조사 AF a1 · a2) — 3D 누름 길 통째. + +까닭: 띠 누름을 마커 듣개가 켜고(누를 때) 3D 부재 고름이 뗄 때 「빈 곳 누름」으로 읽어 고름 전체를 지움. +고침: ① 마커 · 띠에 맞은 누름은 부재 고름이 건너뜀 ② 숨은 핀 · 꺼 둔 측점선 · 라벨은 누름을 안 가로챔 +③ 3D 빈 곳 누름 = 고름 안 풂(08-05) · 같은 띠 · 같은 부재 다시 누름 = 풂. +진짜 TS 를 `helper_232_1_3d_press.cjs` 가 three + 가짜 캔버스로 한 누름씩(창 캡처 → 캔버스 듣개 차례) 돌림. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + +RED = "#ef4444" +YELLOW = "#facc15" +AT_120 = {"chainageM": 120, "structureId": None} + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_232_1_3d_press.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_띠_누름은_뗀_뒤에도_빨강_핀_기록_유지(out) -> None: + for phase in ("down", "up"): + state = out["bar"][phase] + assert state["s120"] == RED + assert state["pin"] is True + assert state["session"] == AT_120 + assert state["graph"] == "s120" + + +def test_빈_곳_누름은_고름_안_풂(out) -> None: + assert out["empty"]["up"]["s120"] == RED + assert out["empty"]["up"]["session"] == AT_120 + assert out["memberEmpty"]["up"]["member"] is True + assert out["memberEmpty"]["up"]["session"]["chainageM"] == 160 + + +def test_같은_띠_다시_누름은_풂(out) -> None: + up = out["again"]["up"] + assert (up["s120"], up["pin"], up["session"], up["graph"]) == (YELLOW, False, None, None) + + +def test_숨은_핀_라벨이_있던_띠_가운데도_켜짐(out) -> None: + assert out["center"]["up"]["s120"] == RED + assert out["center"]["up"]["session"] == AT_120 + + +def test_부재_고름과_같은_부재_다시_누름(out) -> None: + up = out["memberPick"]["up"] + assert up["member"] is True + assert up["session"] == {"chainageM": 160, "structureId": None, "partKey": "inlet"} + again = out["memberAgain"]["up"] + assert again["member"] is False + assert again["session"] is None + + +def test_꺼_둔_측점선은_누름을_안_가로챔(out) -> None: + up = out["hiddenLines"]["up"] + assert up["s100"] == YELLOW + assert up["session"] == AT_120 # 앞 고름 그대로 · 빈 곳 누름이라 안 풂 + assert out["hiddenPressedMarker"] is False + + +def test_배선() -> None: + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Viewer.ts"), encoding="utf-8" + ) as handle: + viewer = handle.read() + assert "pressedMarker: () => markerInput.pressedMarker()" in viewer