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:
2026-09-04 15:04:38 +09:00
co-authored by Claude Opus 5
parent e0e9d819c4
commit 2878ab9617
10 changed files with 440 additions and 47 deletions
+4 -1
View File
@@ -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],
+5
View File
@@ -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 — 예전에는 재계산·재진입 때만 반영). */
+5
View File
@@ -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);
};
}
+32 -39
View File
@@ -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;
}
+27
View File
@@ -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();
}
}