diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 0541eadc..5ef4a509 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -222,7 +222,10 @@ function fnv1a(text: string): string { // 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 // 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — // 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. -const BUILD_VERSION = 90; +// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 +// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 +// 부재를 못 정하므로 만료한다. +const BUILD_VERSION = 91; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index a6e39ba3..855fd589 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -496,10 +496,16 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단 계산 그대로의 솔리드 // (2026-08-23 사용자). 리본 격자와 무관한 독립 물체라 개별 Mesh로 얹는다. (build.structures ?? []).forEach((structure, index) => { + // 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다 + // (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다. + const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key }; if (structure.kind === "pipe") { const mesh = structurePipeMesh(structure, origin); if (mesh) { mesh.name = `corridor-structure:pipe:${index}`; + mesh.traverse((object) => { + object.userData = tag; + }); group.add(mesh); } return; @@ -517,6 +523,7 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou }), ); mesh.name = `corridor-structure:${structure.kind}:${index}`; + mesh.userData = tag; group.add(mesh); // 모서리 검은선 — 성토부·노폭 리본과 같은 규칙으로 경계를 또렷하게 // (2026-08-23 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 230aef90..c0b058cb 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -73,6 +73,12 @@ export interface RouteFrame { export interface CorridorStructure { chainage_m: number; kind: "revet" | "basin" | "pipe"; + /** + * 부재키 — B06이 쓰는 이름 그대로(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`· + * `basin`·`pipe`). 3D에서 부재 하나를 고르고 그 선택을 B06 카드·조정창으로 넘기는 + * 신원표다(2026-09-04). 세월교·BOX암거 부재는 아직 비워 둔다. + */ + key?: string; /** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */ polygon?: Array<[number, number]>; /** @@ -303,6 +309,8 @@ export function buildCorridorStructures( afterM: number, /** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */ path?: WallPath | null, + /** 부재키(2026-09-04 3D 개별 선택) — 없으면 신원표 없는 부재로 남는다. */ + key?: string, ): void => { if (points.length < 3) return; const chainages = ringChainagesOf(beforeM, afterM); @@ -311,6 +319,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind, + key, polygon: points.map((p) => [p.offset, p.elevation]), rings, }); @@ -319,6 +328,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind, + key, polygons: chainages.map((at) => polygonAt(path, at)), rings, }); @@ -327,7 +337,14 @@ export function buildCorridorStructures( if (revetLayout) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. for (const tier of revetLayout.tiers) { - pushSwept("revet", tier.polygon, revetLayout.span.beforeM, revetLayout.span.afterM); + pushSwept( + "revet", + tier.polygon, + revetLayout.span.beforeM, + revetLayout.span.afterM, + null, + "own", + ); } } @@ -365,6 +382,8 @@ export function buildCorridorStructures( boreChainages: number[] = [chainage], /** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */ path?: WallPath | null, + /** 부재키(2026-09-04 3D 개별 선택). */ + key?: string, ): void => { if (points.length < 3) return; const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]); @@ -372,7 +391,7 @@ export function buildCorridorStructures( const right = Math.min(...polygon.map(([offset]) => offset)); // 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다. if (left < bore.minOffset || right > bore.maxOffset) { - pushSwept(kind, points, beforeM, afterM, path); + pushSwept(kind, points, beforeM, afterM, path, key); return; } // 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다. @@ -417,7 +436,7 @@ export function buildCorridorStructures( for (const pieces of [upper, lower]) { // 어느 링에서든 비면 그 조각은 통째로 버린다 — 링 토폴로지를 맞춰야 한다. if (pieces.some((piece) => piece.length < 3)) continue; - solids.push({ chainage_m: chainage, kind, polygons: pieces, rings }); + solids.push({ chainage_m: chainage, kind, key, polygons: pieces, rings }); } }; @@ -492,6 +511,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: at, kind: "pipe", + key: "pipe", pipe: { start: [invert[3].offset, invert[3].elevation], end: [invert[2].offset, invert[2].elevation], @@ -537,19 +557,42 @@ export function buildCorridorStructures( const span = wall.role === "inlet" ? inletSpan : outletSpan; const path = pathOf(wall.role, wall.points); // 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다. - if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path); + if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path, wall.role); else - pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path); + pushPierced( + "revet", + wall.points, + span.beforeM, + span.afterM, + culvertBore, + [chainage], + path, + wall.role, + ); } // 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다). // 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`). layout.extraWalls.forEach((wall, i) => { const span = tierSpanOf(section, `extra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points)); + pushSwept( + "revet", + wall.points, + span.beforeM, + span.afterM, + pathOf(`extra${i}`, wall.points), + `extra${i}`, + ); }); layout.basinExtras.forEach((wall, i) => { const span = tierSpanOf(section, `bextra${i}`); - pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points)); + pushSwept( + "revet", + wall.points, + span.beforeM, + span.afterM, + pathOf(`bextra${i}`, wall.points), + `bextra${i}`, + ); }); if (layout.basin) { @@ -579,6 +622,9 @@ export function buildCorridorStructures( basinSpan.beforeM, basinSpan.afterM, culvertBore, + [chainage], + null, + "basin", ); } } @@ -588,6 +634,7 @@ export function buildCorridorStructures( solids.push({ chainage_m: chainage, kind: "pipe", + key: "pipe", pipe: { start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation], end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation], diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 95ba5839..d545d0fe 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -27,6 +27,7 @@ import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; +import { wireStructurePick } from "./B05_Profile_UI_Structure_Pick_Session"; import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; import { createRouteViewer } from "./B05_Profile_UI_Viewer"; import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations"; @@ -204,6 +205,7 @@ export async function renderB05Route(root: HTMLElement): Promise { // 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(2026-08-17 전역 선택 동기화). selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), + selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM), }); function persistUphillOverrides(): void { @@ -321,6 +323,9 @@ export async function renderB05Route(root: HTMLElement): Promise { }, }); + // 3D 코리도에서 부재를 고르면 좌측 「구조물 배치」 폼이 그 시설을 연다(2026-09-04). + wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe); + /** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정). * 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시** * 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */ diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index e0fa8b92..baf289fd 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -34,6 +34,9 @@ export interface SelectionSyncPorts { * 마커·유역 강조가 따라오게 한다(2026-08-17 전역 선택 동기화). 부속 옵션 폼은 * 사이드 「구조물 배치」가 selectSidebar 경로에서 연다. */ selectPipeForm?: (chainageM: number | null) => void; + /** 3D 코리도 구조물 강조(누가거리 기준) — 좌측 폼·종단에서 골라도 3D가 따라온다 + * (2026-09-04). 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다. */ + selectStructure3D?: (chainageM: number | null) => void; } export interface SelectionSync { @@ -77,6 +80,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { try { ports.selectSidebar(station ? station.chainage_m : null); ports.selectPipeForm?.(station && isPipeStation(station) ? station.chainage_m : null); + ports.selectStructure3D?.(station ? station.chainage_m : null); } finally { ports.setSyncing(false); } @@ -104,6 +108,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { // 유역·마커 어느 쪽에서 왔든 시설 폼도 그 관을 연다(투영 측점이 없어도 // 누가거리로 근사 매칭 — Drainage 쪽이 못 찾으면 해제된다). ports.selectPipeForm?.(matched ? matched.chainage_m : chainageM); + ports.selectStructure3D?.(matched ? matched.chainage_m : chainageM); } finally { ports.setSyncing(false); } diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts new file mode 100644 index 00000000..1caaad38 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -0,0 +1,86 @@ +/* ============================================================================= + * B05_Profile_UI_Structure_Pick_Session.ts + * 3D에서 고른 구조물을 **좌측 「구조물 배치」 패널로 넘기고**, 그 선택을 세션 한 칸에 + * 남겨 **B06 진입 때 같은 측점 카드가 열리게** 한다(2026-09-04 사용자 확정). + * + * B05·B06은 라우터가 따로 띄우는 화면이라 실시간 양방향이 아니다 — 조작 상태는 캐시 + * 몫이라는 데이터 3층 규칙대로 sessionStorage에 남긴다. 값은 읽는 즉시 지운다(한 번만 + * 따라간다 — 나중에 B06을 다시 열었을 때 옛 선택이 되살아나면 안 된다). + * ========================================================================== */ + +import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; +import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; +import type { StructureInstance } from "./B05_Profile_Api_Structures"; + +/** B06으로 넘기는 선택 — B06이 쓰는 `{ 측점, 부재키 }` 한 쌍과 같은 모양이다. */ +export interface StructurePickHandoff { + at: number; + key?: string; +} + +const sessionKey = (projectId: string): string => `aislo:structure-pick:${projectId}`; + +/** 세션에 남긴다(선택 해제면 지운다). */ +export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void { + if (!projectId) return; + try { + if (!pick) { + window.sessionStorage.removeItem(sessionKey(projectId)); + return; + } + const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key }; + window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(handoff)); + } catch { + /* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */ + } +} + +/** 세션에 남은 선택을 꺼내고 지운다. 없으면 null. */ +export function consumeStructurePick(projectId: string | null): StructurePickHandoff | null { + if (!projectId) return null; + try { + const raw = window.sessionStorage.getItem(sessionKey(projectId)); + if (!raw) return null; + window.sessionStorage.removeItem(sessionKey(projectId)); + const value = JSON.parse(raw) as Partial; + if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null; + return { at: value.at, key: typeof value.key === "string" ? value.key : undefined }; + } catch { + return null; + } +} + +/** 구간형 구조물이 그 누가거리를 덮는가(기준점형은 ±0.51m 안). */ +function covers(entry: StructureInstance, chainageM: number): boolean { + const start = entry.start_m ?? entry.chainage_m; + const end = entry.end_m ?? entry.chainage_m; + if (start == null || end == null) return false; + return chainageM >= Math.min(start, end) - 0.51 && chainageM <= Math.max(start, end) + 0.51; +} + +/** + * 3D 클릭 결과를 화면 전체에 적용한다. + * + * 배수관 세트(기슭막이·집수정·배관)는 `selectStation`(=기존 선택 동기화)이 좌측 폼·종단 + * 그래프·3D 마커·유역도를 한 줄로 맞춘다. 그 줄에서 아무것도 안 잡히면 옛 정본 구조물로 + * 보고 구조물 id로 연다. + */ +export function wireStructurePick( + controls: StructurePickControls, + projectId: string | null, + structures: StructuresSection, + /** 기존 선택 동기화(`selectStationOfPipe`) — 누가거리 하나로 전 화면을 맞춘다. */ + selectStation: (chainageM: number | null) => void, +): void { + controls.onPick = (pick: StructurePick | null): void => { + rememberStructurePick(projectId, pick); + if (!pick) { + selectStation(null); + return; + } + selectStation(pick.chainageM); + if (structures.hasSelection()) return; + const hit = structures.getStructures().find((entry) => covers(entry, pick.chainageM)); + structures.selectById(hit?.structure_id ?? null); + }; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index d098c6df..04bcd643 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -23,6 +23,11 @@ import { PLAN_CURVE_GROUP, } from "./B05_Profile_UI_Corridor_Mesh"; import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; +import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; +import { + bindStructurePick, + type StructurePickControls, +} from "./B05_Profile_UI_Viewer_Structure_Pick"; import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; @@ -116,6 +121,8 @@ export interface RouteViewer { beginMoveSelected: () => void; /** 화면(client) 좌표 아래 지형의 모델 좌표 — 3D 우클릭 구조물 배치용(2026-08-19). */ modelPointAt: (clientX: number, clientY: number) => { x: number; y: number; z: number } | null; + /** 코리도 구조물 개별 선택(2026-09-04) — 클릭 알림·강조 되살리기 창구. */ + structurePick: StructurePickControls; dispose: () => void; } @@ -206,6 +213,9 @@ export function createRouteViewer(): RouteViewer { (window as unknown as { __corridorScene?: unknown }).__corridorScene = { scene, THREE, + // 카메라도 함께 낸다(2026-09-04) — 화면 좌표에서 레이캐스트로 무엇이 앞에 있는지 + // 확인해야 3D 클릭 검증을 수치로 할 수 있다. + camera, toScene: (x: number, y: number, z: number) => bounds ? modelToScene({ x, y, z }, bounds) : null, topZ: () => (bounds ? bounds.z[1] + 100 : null), @@ -216,6 +226,17 @@ export function createRouteViewer(): RouteViewer { placeCamera(modelToScene({ x, y, z }, bounds), distance, view); return true; }, + // 모델 좌표 한 점의 **화면(client) 좌표**(2026-09-04) — 3D 물체를 실제 마우스로 + // 눌러 검증할 때 쓴다. 캔버스가 아래 패널에 가려 중앙이 안 보이므로 자리를 직접 잰다. + project: (x: number, y: number, z: number) => { + if (!bounds) return null; + const point = modelToScene({ x, y, z }, bounds).project(camera); + const rect = canvas.getBoundingClientRect(); + return { + x: rect.left + ((point.x + 1) / 2) * rect.width, + y: rect.top + ((1 - point.y) / 2) * rect.height, + }; + }, }; const markers = createRouteMarkers(scene, () => bounds, terrainElevation); // 캔버스 포인터 입력(마커 끌기·고르기·끌어놓기)은 따로 뗐다(2026-09-02, 700줄 제한). @@ -227,6 +248,13 @@ export function createRouteViewer(): RouteViewer { getTerrain: () => terrain, getBounds: () => bounds, }); + // 코리도 구조물 클릭 선택(2026-09-04) — 마커보다 뒤 순위다. + const structurePick = bindStructurePick({ + canvas, + camera, + group: () => corridorGroup, + blocked: () => markerInput.blocked(), + }); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다. const releaseCursorPivot = bindCursorPivotControls({ @@ -536,50 +564,13 @@ export function createRouteViewer(): RouteViewer { function setCorridor(build: CorridorBuildResult | null): void { disposeCorridorGroup(); corridorBuild = build; - // 검증용 요약(2026-08-26) — 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 - // 수치로 확인한다(`__corridorClip`과 같은 용도). - (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build - ? { - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - rows: ribbon.chainages.length, - cols: ribbon.colCount, - first: ribbon.chainages[0], - last: ribbon.chainages[ribbon.chainages.length - 1], - })), - outlineRows: build.outline.chainages.length, - outline: build.outline, - // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). - structures: (build.structures ?? []).map((solid) => ({ - kind: solid.kind, - at: solid.chainage_m, - rings: solid.rings?.length ?? 0, - points: solid.polygon?.length ?? 0, - })), - // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. - raw: { - ribbons: build.ribbons, - planCurves: build.planCurves ?? [], - cutWalls: build.cutWalls ?? [], - }, - planCurves: (build.planCurves ?? []).map((curve) => ({ - source: curve.source, - role: curve.role, - side: curve.side, - at: curve.setChainageM, - loops: curve.loops.length, - points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), - z0: curve.loops[0]?.[0]?.[2] ?? null, - planZ: curve.planZ, - })), - } - : null; + setCorridorBuildSummary(build); if (build && bounds) { snapCorridorEdges(build); attachPatchSkirts(build); corridorGroup = createCorridorGroup(build, bounds); scene.add(corridorGroup); + structurePick.reapply(); } // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). @@ -605,6 +596,7 @@ export function createRouteViewer(): RouteViewer { return { root, markers, + structurePick, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { bounds = nextBounds; current = { projectId, modelId, smooth, interval }; @@ -682,6 +674,7 @@ export function createRouteViewer(): RouteViewer { markers.dispose(); clearContours(); disposeObject(terrain); + structurePick.dispose(); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Debug.ts b/B05_Profile/B05_Profile_UI_Viewer_Debug.ts new file mode 100644 index 00000000..b5964869 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Debug.ts @@ -0,0 +1,55 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Debug.ts + * 뷰어 **검증용 요약** — 코리도 빌드 결과를 `window.__corridorBuild` 에 얹는다 + * (`__corridorClip`·`__corridorScene` 과 같은 용도). + * + * `B05_Profile_UI_Viewer` 에서 그대로 떼어냈다(700줄 제한, 2026-09-04). 계산은 없고 + * 요약 형태만 있으므로 화면 동작에는 영향이 없다. + * ========================================================================== */ + +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; + +/** + * 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 수치로 확인한다(2026-08-26). + * `raw` 는 원본 참조라 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. + */ +export function setCorridorBuildSummary(build: CorridorBuildResult | null): void { + (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build + ? { + ribbons: build.ribbons.map((ribbon) => ({ + kind: ribbon.kind, + side: ribbon.side, + rows: ribbon.chainages.length, + cols: ribbon.colCount, + first: ribbon.chainages[0], + last: ribbon.chainages[ribbon.chainages.length - 1], + })), + outlineRows: build.outline.chainages.length, + outline: build.outline, + // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). + // 부재키(2026-09-04)도 함께 낸다 — 3D 개별 선택이 무엇을 집었는지 대조한다. + structures: (build.structures ?? []).map((solid) => ({ + kind: solid.kind, + key: solid.key ?? null, + at: solid.chainage_m, + rings: solid.rings?.length ?? 0, + points: solid.polygon?.length ?? 0, + })), + raw: { + ribbons: build.ribbons, + planCurves: build.planCurves ?? [], + cutWalls: build.cutWalls ?? [], + }, + planCurves: (build.planCurves ?? []).map((curve) => ({ + source: curve.source, + role: curve.role, + side: curve.side, + at: curve.setChainageM, + loops: curve.loops.length, + points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), + z0: curve.loops[0]?.[0]?.[2] ?? null, + planZ: curve.planZ, + })), + } + : null; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts new file mode 100644 index 00000000..5e466ec5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Viewer_Structure_Pick.ts @@ -0,0 +1,165 @@ +/* ============================================================================= + * B05_Profile_UI_Viewer_Structure_Pick.ts + * 3D 코리도 **구조물 개별 선택**(2026-09-04 사용자 지시) — 부재 하나를 클릭으로 골라 + * 밝히고, 고른 신원표를 바깥으로 넘긴다. 정보상자는 만들지 않는다(좌측 「구조물 배치」 + * 패널이 그 일을 한다). + * + * 뷰어·마커 입력에 얹지 않고 새 모듈로 뒀다(700줄 제한). 규칙 두 가지 — + * · **마커보다 뒤 순위**: 마커를 잡고 있거나 이동 대기 중이면 선택을 건너뛴다. + * · **누른 자리에서 3px 안에서 뗐을 때만** 선택으로 본다(카메라 회전과 구분). + * ========================================================================== */ + +import * as THREE from "three"; + +/** 3D에서 고른 부재의 신원표 — 메쉬 `userData`에 실려 있는 그 값이다. */ +export interface StructurePick { + chainageM: number; + kind: string; + /** 부재키(`inlet`·`outlet`·`extra{i}`·`bextra{i}`·`own`·`basin`·`pipe`). 없을 수 있다. */ + key?: string; +} + +export interface StructurePickControls { + /** 3D에서 골랐을 때 알린다. null = 빈 곳(지형·리본) 클릭으로 해제. */ + onPick?: (pick: StructurePick | null) => void; + /** 좌측 패널·종단 그래프에서 고른 것을 3D 강조에 반영한다(부재키 없이 측점만). */ + selectAtChainage: (chainageM: number | null) => void; + /** 코리도를 다시 만든 뒤 강조를 되살린다 — 메쉬가 통째로 새 것이라 다시 칠해야 한다. */ + reapply: () => void; + dispose: () => void; +} + +/** 고른 부재를 밝히는 자체발광색 — 원래 색은 그대로 두고 밝기만 얹는다. */ +const HIGHLIGHT = 0x2f5f9f; + +/** 클릭으로 볼 이동 허용치(px) — 마커 끌기 판정과 같은 값. */ +const CLICK_SLOP_PX = 3; + +export function bindStructurePick(options: { + canvas: HTMLCanvasElement; + camera: THREE.Camera; + /** 코리도 그룹 — 없거나 꺼져 있으면 고르지 않는다. */ + group: () => THREE.Object3D | null; + /** 마커를 잡고 있거나 이동 대기 중인가 — 참이면 구조물 선택을 건너뛴다. */ + blocked: () => boolean; +}): StructurePickControls { + const { canvas, camera, group, blocked } = options; + + let selected: StructurePick | null = null; + let down: { x: number; y: number; pointerId: number } | null = null; + // 마커 "이동 대기" 모드는 마커 쪽 pointerdown이 그 자리에서 꺼 버린다 — 그전(창 캡처 + // 단계)에 한 번 물어 둔다. 마커를 집었는지는 그 뒤 캔버스 단계에서 본다. + let blockedBeforeDown = false; + + /** 메쉬(또는 그 부모)에 달린 신원표. 없으면 구조물이 아니다. */ + function tagOf(object: THREE.Object3D | null): StructurePick | null { + let node = object; + while (node) { + const data = node.userData as Partial; + if (typeof data.chainageM === "number") { + return { chainageM: data.chainageM, kind: String(data.kind ?? ""), key: data.key }; + } + node = node.parent; + } + return null; + } + + /** 이 부재가 지금 고른 것인가 — 부재키가 없는 선택(측점만)은 그 측점 전부를 켠다. */ + function matches(tag: StructurePick): boolean { + if (!selected) return false; + if (Math.abs(tag.chainageM - selected.chainageM) >= 0.01) return false; + return selected.key === undefined || tag.key === selected.key; + } + + function applyHighlight(): void { + const root = group(); + if (!root) return; + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh) return; + const tag = tagOf(mesh); + if (!tag) return; + const on = matches(tag); + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const material of materials) { + const lit = material as THREE.MeshLambertMaterial; + // 자체발광이 없는 재질(라인 등)은 건너뛴다. + if (lit.emissive) lit.emissive.setHex(on ? HIGHLIGHT : 0x000000); + } + }); + } + + /** 화면 좌표 아래 **제일 앞 메쉬**의 신원표. 리본·지형이 앞이면 null(=해제)이다. */ + function pickAt(clientX: number, clientY: number): StructurePick | null { + const root = group(); + if (!root || !root.visible) return null; + const rect = canvas.getBoundingClientRect(); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera( + new THREE.Vector2( + ((clientX - rect.left) / rect.width) * 2 - 1, + -((clientY - rect.top) / rect.height) * 2 + 1, + ), + camera, + ); + // 모서리 선(LineSegments)은 뺀다 — 라인 레이캐스트 허용반경이 1m라 클릭을 가로챈다. + const hit = raycaster + .intersectObject(root, true) + .find((entry) => (entry.object as THREE.Mesh).isMesh); + return hit ? tagOf(hit.object) : null; + } + + function handleWindowDown(): void { + blockedBeforeDown = blocked(); + } + + function handleDown(event: PointerEvent): void { + down = + event.button === 0 && !blockedBeforeDown && !blocked() + ? { x: event.clientX, y: event.clientY, pointerId: event.pointerId } + : null; + } + + function handleUp(event: PointerEvent): void { + const start = down; + down = null; + 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); + applyHighlight(); + controls.onPick?.(selected); + } + + function handleExit(): void { + down = null; + } + + // **캡처 단계**로 단다(2026-09-04 실측) — 캔버스에 이미 붙은 캡처 리스너가 pointerdown + // 전파를 멈춰, 같은 캔버스의 버블 리스너는 아예 불리지 않는다(마커 입력·회전 중심 + // 유틸과 같은 자리). 마커 입력보다 **나중에** 달아 순서상 뒤에 선다. + window.addEventListener("pointerdown", handleWindowDown, true); + canvas.addEventListener("pointerdown", handleDown, true); + canvas.addEventListener("pointerup", handleUp, true); + canvas.addEventListener("pointerleave", handleExit, true); + canvas.addEventListener("pointercancel", handleExit, true); + + const controls: StructurePickControls = { + selectAtChainage(chainageM) { + // 3D에서 부재를 집어 이미 그 측점을 고른 상태면 그대로 둔다 — 되돌아온 동기화가 + // 부재 하나짜리 강조를 그 측점 전체로 넓히면 안 된다(2026-09-04). + if (chainageM !== null && selected && Math.abs(selected.chainageM - chainageM) < 0.01) return; + selected = chainageM === null ? null : { chainageM, kind: "" }; + applyHighlight(); + }, + reapply: applyHighlight, + dispose() { + window.removeEventListener("pointerdown", handleWindowDown, true); + canvas.removeEventListener("pointerdown", handleDown, true); + canvas.removeEventListener("pointerup", handleUp, true); + canvas.removeEventListener("pointerleave", handleExit, true); + canvas.removeEventListener("pointercancel", handleExit, true); + }, + }; + return controls; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 7cd9fa6a..6e57c4ac 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -30,6 +30,8 @@ import { } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; +import { consumeStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; +import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -55,6 +57,9 @@ import { loadSectionDetail } from "./B06_Section_Section_Store"; import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; +/** B05 3D가 넘긴 부재키 중 **기슭막이 조정창이 받는 것**(2026-09-04). 집수정·배관은 빠진다. */ +const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+)$/; + export async function renderB06ProfileCross(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; @@ -513,6 +518,27 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { confirmButton.disabled = sectionDetail === null; } + /** + * B05 3D에서 고른 구조물을 이어받는다(2026-09-04 사용자 확정) — 세션 한 칸에 남긴 + * `{ 측점, 부재키 }`로 그 측점 카드를 고르고, 기슭막이 부재면 조정창까지 연다. + * 값은 읽는 즉시 지워지므로 진입 한 번만 따라간다. + */ + function restoreStructurePick(): void { + const handoff = consumeStructurePick(projectId); + if (!handoff) return; + const target = sectionDetail?.cross_sections.find( + (section) => Math.abs(section.chainage_m - handoff.at) < 0.01, + ); + if (!target) return; + // 카드가 다 선 뒤에 고른다 — 스크롤이 자리를 잡아야 한다. + requestAnimationFrame(() => { + sectionView.focusStation(target.station_id); + if (!handoff.key || !REVET_KEYS.test(handoff.key)) return; + stationControls.revetOffset.select(target.chainage_m, handoff.key as RevetKey); + sectionView.refreshCard(target.chainage_m); + }); + } + function renderSectionDetail(): void { if (sectionDetail) { showSectionView(); @@ -527,6 +553,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { `${projectId ?? "-"}:${currentRouteId ?? "-"}`, context?.natural_spoil_min_ground_slope ?? undefined, ); + restoreStructurePick(); } }