feat(B05): 3D 코리도 구조물 개별 선택
- 구조물 솔리드에 부재키(key) 부여 — B06과 같은 이름(inlet·outlet·extra{i}·bextra{i}·own·basin·pipe)
- 구조물 메쉬에 신원표(userData) — 이름 규칙은 그대로 유지
- 신규 모듈 두 개 — 3D 클릭·강조(Viewer_Structure_Pick), 좌측 패널 연결·B06 넘김(Structure_Pick_Session)
- 좌측 패널·종단에서 고른 것도 3D가 따라오도록 선택 동기화에 포트 추가
- B06 진입 시 세션 넘김값으로 그 측점 카드 선택·스크롤 + 기슭막이 조정창 열기
- 저장 코리도 만료(BUILD_VERSION 90→91) — 옛 저장본엔 부재키가 없음
- 뷰어 700줄 여유 — 검증용 요약을 Viewer_Debug로 분리(동작 불변)
- 검증 수단 — __corridorScene에 camera·project 추가
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<void> {
|
||||
// 배관이면 배수유역 마커 선택 + 부속 옵션 폼까지 연다(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<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// 3D 코리도에서 부재를 고르면 좌측 「구조물 배치」 폼이 그 시설을 연다(2026-09-04).
|
||||
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
|
||||
|
||||
/** 입력·마커 변경 시 측점 라인만 다시 그린다 — 확정 게이트는 폐지(B06 통합 확정).
|
||||
* 지형 구분·등급·기울기 세부 입력은 종단 그래프의 위반 판정 기준까지 **즉시**
|
||||
* 갈아 끼운다(2026-08-19 사용자 지시 6 — 예전에는 재계산·재진입 때만 반영). */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<StructurePickHandoff>;
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<StructurePick>;
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
let currentRouteId: number | null = null;
|
||||
@@ -513,6 +518,27 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
|
||||
context?.natural_spoil_min_ground_slope ?? undefined,
|
||||
);
|
||||
restoreStructurePick();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user