fix(b05): 3D 띠 누름 뒤 강조 유지 · 숨은 핀 · 꺼 둔 측점선 누름 안 가로챔 (232-1)

- 마커 · 띠에 맞은 누름은 3D 부재 고름이 빈 곳 누름으로 읽지 않음(pressedMarker)
- 3D 빈 곳 누름 = 고름 안 풂 · 같은 띠 · 같은 부재 다시 누름 = 풂
- 핀 · 라벨 = 표시만(레이캐스트 뺌) · 숨은 측점선은 누름 대상에서 뺌
- 3D 누름 길 통째 시험(three + 가짜 캔버스)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 18:48:57 +09:00
co-authored by Claude Opus 5.5
parent d34f6b85ca
commit b521328bf9
7 changed files with 340 additions and 7 deletions
+7 -1
View File
@@ -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;
}
@@ -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);
+1
View File
@@ -259,6 +259,7 @@ export function createRouteViewer(): RouteViewer {
camera: cameraRig.camera,
group: () => corridor.group(),
blocked: () => markerInput.blocked(),
pressedMarker: () => markerInput.pressedMarker(),
});
// 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸).
// 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.
@@ -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);
@@ -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);
}
+213
View File
@@ -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));
+91
View File
@@ -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