/* 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, }); // 고름 강조 한 규칙(323-5) — 진짜 `design_pick_match`(같은 자리 = 고름 모듈 거리) const PM = loadTs("A00_Common/design/design_pick_match.ts", { "./design_selection": DSel }); const SPK = loadTs("B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts", { "../A00_Common/design/design_pick_match": PM, "../A00_Common/design/design_selection": DSel, }); 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));