feat(B05): 3D 예상형상 — 계획노선 코리도 서피스·원지반 경계 재절단·영구저장
- Corridor_Build: design_line을 차도/노견/측구/절토/성토 5종으로 분류, catch point(설계선-지반 교차)로 점유 폭 산출, 노선 폴리라인 2m 세분 중간 프레임 보간 (계산식 재구현 없이 백엔드 design_line 소비만) - Corridor_Mesh: 종류별 색 리본 BufferGeometry 조합(멀티 스윕) - Corridor_Clip: catch line 스트립으로 지형 삼각형 경계 재절단(셀 단위 판정·정점색 보간), 원본 보존 — [예상형상] 토글 OFF 시 원지반 완전체 복원 - Viewer: 원본/클리핑 지형 두 벌 스왑, 비탈 최외곽 z 지형 투영(심 보정), 클립 rAF 비동기, 흑백·dispose 연동 - Panel: [예상형상] 토글 (기본 ON) - Corridor 엔트리: 설계 입력 FNV-1a 해시 버전, base64 Float32 JSON 직렬화, 최초 빌드 즉시 저장 + 임시저장·페이지 이동 시 dirty 저장, SPA 메모리 캐시 - Router_Corridor(신규)+main 등록: GET/PUT corridor 파일 보관 (B05_Profile/corridor/, atomic, 64MB 상한) - 검증: tsc·ruff·pytest 5/5, 공용 브라우저 실조작(토글·B06 변경 왕복· 저장 왕복 네트워크 실측), 저장본 리본 수치 대조 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""B05 3D 예상형상(코리도) 서피스 영구저장 라우터.
|
||||
|
||||
코리도 메쉬는 브라우저(TS 단일 구현)가 종횡단 정본으로 빌드한 **파생 데이터**다.
|
||||
여기서는 파일 보관·조회만 한다 — 기하 계산 없음(2026-08-23 계획).
|
||||
저장 흐름: 초기 빌드 직후 1회 + 임시저장·종단/횡단 페이지 이동 시 갱신.
|
||||
검증(버전 해시)은 프론트가 종횡단 정본과 대조한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"])
|
||||
|
||||
# 코리도 JSON 상한 — 리본 좌표(base64 Float32) 기준 넉넉히. 초과는 비정상 요청.
|
||||
MAX_CORRIDOR_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json"
|
||||
|
||||
|
||||
async def _resolve_project_root(project_id: UUID) -> Path | None:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, str(project_id))
|
||||
if not stored_path:
|
||||
return None
|
||||
return Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
|
||||
@router.get("/{project_id}/routes/{route_id}/corridor", response_model=None)
|
||||
async def get_corridor(project_id: UUID, route_id: int) -> Response:
|
||||
"""저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백)."""
|
||||
try:
|
||||
project_root = await _resolve_project_root(project_id)
|
||||
if project_root is None:
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
if not path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "저장된 코리도가 없습니다."},
|
||||
)
|
||||
payload = await asyncio.to_thread(path.read_bytes)
|
||||
return Response(content=payload, media_type="application/json")
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"B05 코리도 조회 실패: project_id=%s route_id=%s error=%s", project_id, route_id, exc
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500, content={"status": "error", "message": "코리도 조회에 실패했습니다."}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/routes/{route_id}/corridor", response_model=None)
|
||||
async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...)) -> JSONResponse:
|
||||
"""코리도 파일 저장(원자적 덮어쓰기). 내용 검증은 최소 — 파생 데이터 보관용."""
|
||||
if not isinstance(payload, dict) or "ribbons" not in payload:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"status": "error", "message": "코리도 형식이 올바르지 않습니다."},
|
||||
)
|
||||
try:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(encoded.encode("utf-8")) > MAX_CORRIDOR_BYTES:
|
||||
return JSONResponse(
|
||||
status_code=413,
|
||||
content={"status": "error", "message": "코리도 데이터가 허용 크기를 넘습니다."},
|
||||
)
|
||||
project_root = await _resolve_project_root(project_id)
|
||||
if project_root is None:
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(atomic_write_json, path, payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"B05 코리도 저장 실패: project_id=%s route_id=%s error=%s", project_id, route_id, exc
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500, content={"status": "error", "message": "코리도 저장에 실패했습니다."}
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor.ts
|
||||
* 코리도(3D 예상형상) 오케스트레이션 — 로드·빌드·저장·캐시.
|
||||
*
|
||||
* 흐름(2026-08-23 사용자 확정):
|
||||
* 1) 초기: 종횡단 정본 로드 시 브라우저가 정밀 빌드 → 표시. 저장본이 없던
|
||||
* 경우에만 즉시 영구저장(PUT).
|
||||
* 2) 재접근: 저장본 GET → 버전 해시가 현 정본과 일치하면 그대로 표시,
|
||||
* 불일치·404면 재빌드(dirty 표시).
|
||||
* 3) 수정: 재빌드는 메모리에서만 — 임시저장·종단/횡단 페이지 이동 시
|
||||
* saveCorridorIfDirty()로 영구저장.
|
||||
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
|
||||
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import {
|
||||
buildCorridor,
|
||||
type CorridorBuildResult,
|
||||
type CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */
|
||||
export interface CorridorStructureHook {
|
||||
/** 구조물이 점유한 측점 구간 [start, end] (chainage m) — 이 구간 리본을 대체한다. */
|
||||
applies: (chainageStart: number, chainageEnd: number) => boolean;
|
||||
}
|
||||
|
||||
interface CorridorEnvelope {
|
||||
version: 1;
|
||||
hash: string;
|
||||
ribbons: Array<{
|
||||
kind: CorridorRibbon["kind"];
|
||||
side: CorridorRibbon["side"];
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positionsBase64: string;
|
||||
}>;
|
||||
outline: CorridorBuildResult["outline"];
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
hash: string;
|
||||
build: CorridorBuildResult;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
const parts: Array<string | number> = [routePoints.length];
|
||||
routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2)));
|
||||
detail.cross_sections.forEach((section) => {
|
||||
const design = section.design;
|
||||
parts.push(
|
||||
section.chainage_m.toFixed(3),
|
||||
section.center_x.toFixed(2),
|
||||
section.center_y.toFixed(2),
|
||||
);
|
||||
if (!design) {
|
||||
parts.push("nodesign");
|
||||
return;
|
||||
}
|
||||
parts.push(
|
||||
design.design_elevation_m.toFixed(3),
|
||||
design.section_mode,
|
||||
design.ditch_side ?? "",
|
||||
design.ditch_type ?? "",
|
||||
String(design.ditch_enabled ?? ""),
|
||||
design.design_line.length,
|
||||
);
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloat32(values: Float32Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function float32FromBase64(encoded: string): Float32Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float32Array(bytes.buffer);
|
||||
}
|
||||
|
||||
function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
return {
|
||||
version: 1,
|
||||
hash,
|
||||
ribbons: build.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloat32(ribbon.positions),
|
||||
})),
|
||||
outline: build.outline,
|
||||
};
|
||||
}
|
||||
|
||||
function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
return {
|
||||
ribbons: envelope.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: float32FromBase64(ribbon.positionsBase64),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCorridor(path: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStored(projectId: string, routeId: number): Promise<CorridorEnvelope | null> {
|
||||
try {
|
||||
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = (await response.json()) as CorridorEnvelope;
|
||||
return payload && payload.version === 1 && Array.isArray(payload.ribbons) ? payload : null;
|
||||
} catch {
|
||||
return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
|
||||
}
|
||||
}
|
||||
|
||||
async function putStored(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
envelope: CorridorEnvelope,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환.
|
||||
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는
|
||||
* saveCorridorIfDirty 시점의 최신 상태를 담는다).
|
||||
*/
|
||||
export async function ensureCorridor(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
detail: SectionDetailResponse,
|
||||
routePoints: RoutePoint[],
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
const key = keyOf(projectId, routeId);
|
||||
const hash = corridorHash(detail, routePoints);
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.hash === hash) return cached.build;
|
||||
|
||||
const stored = await fetchStored(projectId, routeId);
|
||||
if (stored && stored.hash === hash) {
|
||||
try {
|
||||
const build = deserialize(stored);
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
return build;
|
||||
} catch {
|
||||
// 손상 저장본 — 빌드로 폴백.
|
||||
}
|
||||
}
|
||||
|
||||
const build = buildCorridor(detail.cross_sections, routePoints);
|
||||
if (!build) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
if (stored === null) {
|
||||
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
void putStored(projectId, routeId, serialize(build, hash)).then((ok) => {
|
||||
if (!ok) markDirty(projectId, routeId);
|
||||
});
|
||||
} else {
|
||||
// 정본이 저장본보다 새것 — 임시저장·페이지 이동 때 저장한다(사용자 확정 흐름).
|
||||
cache.set(key, { hash, build, dirty: true });
|
||||
}
|
||||
return build;
|
||||
}
|
||||
|
||||
function markDirty(projectId: string, routeId: number): void {
|
||||
const entry = cache.get(keyOf(projectId, routeId));
|
||||
if (entry) entry.dirty = true;
|
||||
}
|
||||
|
||||
/** 임시저장·B05↔B06 페이지 이동 훅 — 미저장 변경분이 있을 때만 PUT. */
|
||||
export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise<void> {
|
||||
const entry = cache.get(keyOf(projectId, routeId));
|
||||
if (!entry || !entry.dirty) return;
|
||||
const ok = await putStored(projectId, routeId, serialize(entry.build, entry.hash));
|
||||
if (ok) entry.dirty = false;
|
||||
}
|
||||
|
||||
/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). */
|
||||
export function refreshCorridor(
|
||||
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
|
||||
projectId: string,
|
||||
routeId: number | undefined,
|
||||
detail: SectionDetailResponse,
|
||||
routePoints: RoutePoint[],
|
||||
): void {
|
||||
if (!routeId) return;
|
||||
void ensureCorridor(projectId, routeId, detail, routePoints)
|
||||
.then((build) => viewer.setCorridor(build))
|
||||
.catch(() => viewer.setCorridor(null));
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Build.ts
|
||||
* 계획노선 코리도(3D 예상형상) **순수 기하 빌더** — Three.js 의존 없음.
|
||||
*
|
||||
* 입력: 종횡단 상세(SectionDetailResponse)의 측점별 설계선(design_line)과
|
||||
* 측점 프레임(center, left_xy), 노선 폴리라인(route_points).
|
||||
* 출력: 종류별(차도/노견/측구/절토비탈/성토비탈) 리본 정점 격자(모델 좌표)와
|
||||
* 좌우 catch line 외곽(원지반 클리핑 경계).
|
||||
*
|
||||
* 원칙(2026-08-23 계획): 계산식 재구현 금지 — 백엔드가 내려준 design_line을
|
||||
* offset 경계로 잘라 분류만 한다. 측점 사이는 노선 폴리라인을 따라 중간
|
||||
* 프레임을 삽입하고 단면을 파라메트릭 보간해 곡선 구간이 각지지 않게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill";
|
||||
export type CorridorSide = "left" | "right" | "center";
|
||||
|
||||
/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */
|
||||
export interface CorridorRibbon {
|
||||
kind: CorridorKind;
|
||||
side: CorridorSide;
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
/** rowCount × colCount × 3 (모델 좌표). */
|
||||
positions: Float32Array;
|
||||
}
|
||||
|
||||
export interface CorridorBuildResult {
|
||||
ribbons: CorridorRibbon[];
|
||||
/** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */
|
||||
outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> };
|
||||
}
|
||||
|
||||
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */
|
||||
const PIECE_COLS: Record<CorridorKind, number> = {
|
||||
carriageway: 3,
|
||||
shoulder: 2,
|
||||
ditch: 5,
|
||||
cut: 9,
|
||||
fill: 9,
|
||||
};
|
||||
|
||||
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). */
|
||||
const SUBDIVIDE_STEP_M = 2;
|
||||
|
||||
interface XY {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface OffsetPoint {
|
||||
offset_m: number;
|
||||
elevation_m: number;
|
||||
}
|
||||
|
||||
/** 측점 하나의 분류·리샘플 결과 — (kind,side)별 colCount 고정 폴리라인. */
|
||||
interface StationPieces {
|
||||
chainage_m: number;
|
||||
center: XY;
|
||||
left: XY;
|
||||
/** kind:side 키 → 리샘플된 단면 점(offset/z). 없는 조각은 키 부재. */
|
||||
pieces: Map<string, OffsetPoint[]>;
|
||||
/** 축퇴 기준점 — 좌/우 road edge (조각이 없는 측점에서 리본 폭 0 수렴용). */
|
||||
roadEdge: { left: OffsetPoint; right: OffsetPoint };
|
||||
/** 최외곽(catch) offset/z — 클리핑 외곽선. */
|
||||
outer: { left: OffsetPoint; right: OffsetPoint };
|
||||
}
|
||||
|
||||
function pieceKey(kind: CorridorKind, side: CorridorSide): string {
|
||||
return `${kind}:${side}`;
|
||||
}
|
||||
|
||||
/** design_line에서 [a,b] 구간 서브폴리라인 추출(경계점은 선형 보간으로 삽입). */
|
||||
function slicePolyline(line: OffsetPoint[], a: number, b: number): OffsetPoint[] {
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
if (hi - lo < 1e-9 || line.length < 2) return [];
|
||||
const zAt = (offset: number): number => {
|
||||
if (offset <= line[0].offset_m) return line[0].elevation_m;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const p0 = line[i - 1];
|
||||
const p1 = line[i];
|
||||
if (offset <= p1.offset_m + 1e-12) {
|
||||
const span = p1.offset_m - p0.offset_m;
|
||||
if (span <= 1e-12) return p1.elevation_m;
|
||||
const t = (offset - p0.offset_m) / span;
|
||||
return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t;
|
||||
}
|
||||
}
|
||||
return line[line.length - 1].elevation_m;
|
||||
};
|
||||
const result: OffsetPoint[] = [{ offset_m: lo, elevation_m: zAt(lo) }];
|
||||
for (const point of line) {
|
||||
if (point.offset_m > lo + 1e-9 && point.offset_m < hi - 1e-9) result.push(point);
|
||||
}
|
||||
result.push({ offset_m: hi, elevation_m: zAt(hi) });
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 폴리라인을 호길이 비례 t(0..1)로 cols개 점으로 리샘플 — 프레임 간 열 대응용. */
|
||||
function resample(points: OffsetPoint[], cols: number): OffsetPoint[] {
|
||||
if (points.length === 0) return [];
|
||||
if (points.length === 1) return Array.from({ length: cols }, () => ({ ...points[0] }));
|
||||
const lengths: number[] = [0];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const dOffset = points[i].offset_m - points[i - 1].offset_m;
|
||||
const dz = points[i].elevation_m - points[i - 1].elevation_m;
|
||||
lengths.push(lengths[i - 1] + Math.hypot(dOffset, dz));
|
||||
}
|
||||
const total = lengths[lengths.length - 1];
|
||||
const result: OffsetPoint[] = [];
|
||||
for (let c = 0; c < cols; c += 1) {
|
||||
const target = total <= 1e-12 ? 0 : (total * c) / (cols - 1);
|
||||
let index = 1;
|
||||
while (index < points.length - 1 && lengths[index] < target) index += 1;
|
||||
const p0 = points[index - 1];
|
||||
const p1 = points[index];
|
||||
const span = lengths[index] - lengths[index - 1];
|
||||
const t = span <= 1e-12 ? 0 : (target - lengths[index - 1]) / span;
|
||||
result.push({
|
||||
offset_m: p0.offset_m + (p1.offset_m - p0.offset_m) * t,
|
||||
elevation_m: p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 지반선 보간기 — samples(offset, elevation)로 지반고를 되짚는다. */
|
||||
function groundSampler(section: CrossSection): ((offset: number) => number) | null {
|
||||
const points = section.samples
|
||||
.map((sample) => ({
|
||||
offset_m: sample.offset_m,
|
||||
elevation_m: sample.elevation_m ?? sample.z,
|
||||
}))
|
||||
.filter(
|
||||
(p): p is OffsetPoint =>
|
||||
p.offset_m !== undefined && p.elevation_m !== undefined && p.elevation_m !== null,
|
||||
)
|
||||
.sort((a, b) => a.offset_m - b.offset_m);
|
||||
if (points.length < 2) return null;
|
||||
return (offset: number): number => {
|
||||
if (offset <= points[0].offset_m) return points[0].elevation_m;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
if (offset <= points[i].offset_m) {
|
||||
const span = points[i].offset_m - points[i - 1].offset_m;
|
||||
const t = span <= 1e-12 ? 0 : (offset - points[i - 1].offset_m) / span;
|
||||
return points[i - 1].elevation_m + (points[i].elevation_m - points[i - 1].elevation_m) * t;
|
||||
}
|
||||
}
|
||||
return points[points.length - 1].elevation_m;
|
||||
};
|
||||
}
|
||||
|
||||
/** 설계선-지반 허용차(m) — 이보다 가까우면 "지반에 닿았다"(catch)로 본다. */
|
||||
const CATCH_EPS_M = 0.005;
|
||||
|
||||
/**
|
||||
* 비탈 시작점에서 바깥으로 스캔해 설계선이 지반과 만나는 catch point를 찾는다.
|
||||
* design_line은 샘플 반폭 끝까지 이어지고 catch 밖은 지반을 그대로 따르므로,
|
||||
* 여기서 잘라야 코리도·클리핑 폭이 실제 절·성토 점유 범위가 된다.
|
||||
*/
|
||||
function catchOffset(
|
||||
line: OffsetPoint[],
|
||||
groundAt: (offset: number) => number,
|
||||
start: number,
|
||||
direction: 1 | -1,
|
||||
end: number,
|
||||
): number {
|
||||
const probes = line
|
||||
.filter((p) => (direction > 0 ? p.offset_m > start + 1e-9 : p.offset_m < start - 1e-9))
|
||||
.sort((a, b) => (a.offset_m - b.offset_m) * direction);
|
||||
for (const probe of probes) {
|
||||
if (Math.abs(probe.elevation_m - groundAt(probe.offset_m)) <= CATCH_EPS_M) {
|
||||
return probe.offset_m;
|
||||
}
|
||||
}
|
||||
return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지.
|
||||
}
|
||||
|
||||
/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */
|
||||
function classifyStation(section: CrossSection): StationPieces | null {
|
||||
const design = section.design;
|
||||
const line = design?.design_line;
|
||||
if (!design || !line || line.length < 2) return null;
|
||||
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
|
||||
const roadL = design.road_edges.left.offset_m;
|
||||
const roadR = design.road_edges.right.offset_m;
|
||||
const cwL = design.carriageway_edges?.left.offset_m ?? roadL;
|
||||
const cwR = design.carriageway_edges?.right.offset_m ?? roadR;
|
||||
|
||||
// 측구 폭 — road edge에서 갭 없이 시작(엔진 ditch_points 규약).
|
||||
const ditch = design.ditch;
|
||||
const ditchEnabled = design.ditch_enabled ?? (ditch != null && ditch.type !== "none");
|
||||
const ditchWidth =
|
||||
!ditchEnabled || !ditch || ditch.type === "none"
|
||||
? 0
|
||||
: ditch.type === "standard"
|
||||
? ditch.top_width_m
|
||||
: ditch.width_m;
|
||||
const ditchSide: "left" | "right" = design.ditch_side === "right" ? "right" : "left";
|
||||
|
||||
// 좌/우 비탈 종류 — 엔진이 echo한 resolved section_mode 기준.
|
||||
const mode = design.section_mode;
|
||||
const leftRole: CorridorKind = mode === "left_cut" || mode === "both_cut" ? "cut" : "fill";
|
||||
const rightRole: CorridorKind = mode === "right_cut" || mode === "both_cut" ? "cut" : "fill";
|
||||
|
||||
const pieces = new Map<string, OffsetPoint[]>();
|
||||
const put = (kind: CorridorKind, side: CorridorSide, a: number, b: number): void => {
|
||||
const sub = slicePolyline(sorted, a, b);
|
||||
if (sub.length >= 2) pieces.set(pieceKey(kind, side), resample(sub, PIECE_COLS[kind]));
|
||||
};
|
||||
|
||||
put("carriageway", "center", cwR, cwL);
|
||||
put("shoulder", "left", cwL, roadL);
|
||||
put("shoulder", "right", roadR, cwR);
|
||||
// 좌측: road edge → (측구) → 비탈끝. 우측은 대칭(음수 방향).
|
||||
let slopeStartL = roadL;
|
||||
let slopeStartR = roadR;
|
||||
if (ditchWidth > 0) {
|
||||
if (ditchSide === "left") {
|
||||
put("ditch", "left", roadL, roadL + ditchWidth);
|
||||
slopeStartL = roadL + ditchWidth;
|
||||
} else {
|
||||
put("ditch", "right", roadR - ditchWidth, roadR);
|
||||
slopeStartR = roadR - ditchWidth;
|
||||
}
|
||||
}
|
||||
// 비탈 끝 = catch point(설계선-지반 교차) — 그 밖은 설계선이 지반을 따라갈 뿐
|
||||
// 절·성토 점유가 아니므로 코리도·클리핑에서 제외한다(2026-08-23 화면 검증 수정).
|
||||
const ground = groundSampler(section);
|
||||
const sampleEndL = sorted[sorted.length - 1].offset_m;
|
||||
const sampleEndR = sorted[0].offset_m;
|
||||
const endL = ground ? catchOffset(sorted, ground, slopeStartL, 1, sampleEndL) : sampleEndL;
|
||||
const endR = ground ? catchOffset(sorted, ground, slopeStartR, -1, sampleEndR) : sampleEndR;
|
||||
if (endL > slopeStartL + 1e-9) put(leftRole, "left", slopeStartL, endL);
|
||||
if (endR < slopeStartR - 1e-9) put(rightRole, "right", endR, slopeStartR);
|
||||
|
||||
const zOf = (target: number): number => {
|
||||
const sub = slicePolyline(sorted, target, target + 1e-6);
|
||||
return sub.length ? sub[0].elevation_m : design.design_elevation_m;
|
||||
};
|
||||
return {
|
||||
chainage_m: section.chainage_m,
|
||||
center: { x: section.center_x, y: section.center_y },
|
||||
left: { x: section.frame.left_xy[0], y: section.frame.left_xy[1] },
|
||||
pieces,
|
||||
roadEdge: {
|
||||
left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m },
|
||||
right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m },
|
||||
},
|
||||
outer: {
|
||||
left: { offset_m: endL, elevation_m: zOf(endL) },
|
||||
right: { offset_m: endR, elevation_m: zOf(endR) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */
|
||||
function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) => XY) | null {
|
||||
const points = routePoints.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||
if (points.length < 2) return null;
|
||||
const cumulative: number[] = [0];
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
cumulative.push(
|
||||
cumulative[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y),
|
||||
);
|
||||
}
|
||||
return (chainage: number): XY => {
|
||||
const target = Math.min(Math.max(chainage, 0), cumulative[cumulative.length - 1]);
|
||||
let index = 1;
|
||||
while (index < points.length - 1 && cumulative[index] < target) index += 1;
|
||||
const span = cumulative[index] - cumulative[index - 1];
|
||||
const t = span <= 1e-12 ? 0 : (target - cumulative[index - 1]) / span;
|
||||
return {
|
||||
x: points[index - 1].x + (points[index].x - points[index - 1].x) * t,
|
||||
y: points[index - 1].y + (points[index].y - points[index - 1].y) * t,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** 좌측 단위벡터 각도 보간(최단각) — 측점 프레임과 일관된 중간 프레임 방향. */
|
||||
function slerpLeft(a: XY, b: XY, t: number): XY {
|
||||
const angleA = Math.atan2(a.y, a.x);
|
||||
let delta = Math.atan2(b.y, b.x) - angleA;
|
||||
if (delta > Math.PI) delta -= Math.PI * 2;
|
||||
if (delta < -Math.PI) delta += Math.PI * 2;
|
||||
const angle = angleA + delta * t;
|
||||
return { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
}
|
||||
|
||||
function lerpPoints(a: OffsetPoint[], b: OffsetPoint[], t: number): OffsetPoint[] {
|
||||
return a.map((p, i) => ({
|
||||
offset_m: p.offset_m + (b[i].offset_m - p.offset_m) * t,
|
||||
elevation_m: p.elevation_m + (b[i].elevation_m - p.elevation_m) * t,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 조각이 없는 측점의 대응 폴리라인 — road edge 한 점으로 축퇴(리본 폭 0 수렴). */
|
||||
function degeneratePiece(
|
||||
station: StationPieces,
|
||||
kind: CorridorKind,
|
||||
side: CorridorSide,
|
||||
): OffsetPoint[] {
|
||||
const edge = side === "right" ? station.roadEdge.right : station.roadEdge.left;
|
||||
return Array.from({ length: PIECE_COLS[kind] }, () => ({ ...edge }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 코리도 빌드 — 측점별 분류 후 노선 폴리라인을 따라 세분·보간해 종류별 리본과
|
||||
* 클리핑 외곽선을 만든다. design 있는 측점이 2개 미만이면 null.
|
||||
*/
|
||||
export function buildCorridor(
|
||||
crossSections: CrossSection[],
|
||||
routePoints: RoutePoint[],
|
||||
subdivideStepM: number = SUBDIVIDE_STEP_M,
|
||||
): CorridorBuildResult | null {
|
||||
const stations = crossSections
|
||||
.map(classifyStation)
|
||||
.filter((s): s is StationPieces => s !== null)
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (stations.length < 2) return null;
|
||||
|
||||
const sampler = buildPolylineSampler(routePoints);
|
||||
// 등장하는 (kind,side) 전체 — 리본 목록 확정.
|
||||
const keys = new Set<string>();
|
||||
stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key)));
|
||||
|
||||
const rows: Array<{
|
||||
chainage_m: number;
|
||||
center: XY;
|
||||
left: XY;
|
||||
sections: Map<string, OffsetPoint[]>;
|
||||
outerLeft: OffsetPoint;
|
||||
outerRight: OffsetPoint;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < stations.length - 1; i += 1) {
|
||||
const s0 = stations[i];
|
||||
const s1 = stations[i + 1];
|
||||
const span = s1.chainage_m - s0.chainage_m;
|
||||
if (span <= 1e-6) continue;
|
||||
const steps = Math.max(1, Math.ceil(span / Math.max(0.5, subdivideStepM)));
|
||||
const last = i === stations.length - 2;
|
||||
for (let k = 0; k <= (last ? steps : steps - 1); k += 1) {
|
||||
const t = k / steps;
|
||||
const chainage = s0.chainage_m + span * t;
|
||||
const center =
|
||||
t === 0
|
||||
? s0.center
|
||||
: t === 1
|
||||
? s1.center
|
||||
: (sampler?.(chainage) ?? {
|
||||
x: s0.center.x + (s1.center.x - s0.center.x) * t,
|
||||
y: s0.center.y + (s1.center.y - s0.center.y) * t,
|
||||
});
|
||||
const left = slerpLeft(s0.left, s1.left, t);
|
||||
const sections = new Map<string, OffsetPoint[]>();
|
||||
keys.forEach((key) => {
|
||||
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
|
||||
// 한쪽 측점에 없는 조각은 road edge로 축퇴시켜 리본 폭이 0으로 수렴(전이 구간).
|
||||
const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side);
|
||||
const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side);
|
||||
sections.set(key, lerpPoints(pa, pb, t));
|
||||
});
|
||||
const lerpOuter = (a: OffsetPoint, b: OffsetPoint): OffsetPoint => ({
|
||||
offset_m: a.offset_m + (b.offset_m - a.offset_m) * t,
|
||||
elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t,
|
||||
});
|
||||
rows.push({
|
||||
chainage_m: chainage,
|
||||
center,
|
||||
left,
|
||||
sections,
|
||||
outerLeft: lerpOuter(s0.outer.left, s1.outer.left),
|
||||
outerRight: lerpOuter(s0.outer.right, s1.outer.right),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [
|
||||
row.center.x + row.left.x * point.offset_m,
|
||||
row.center.y + row.left.y * point.offset_m,
|
||||
point.elevation_m,
|
||||
];
|
||||
|
||||
const ribbons: CorridorRibbon[] = [];
|
||||
keys.forEach((key) => {
|
||||
const [kind, side] = key.split(":") as [CorridorKind, CorridorSide];
|
||||
const colCount = PIECE_COLS[kind];
|
||||
const chainages: number[] = [];
|
||||
const positions = new Float32Array(rows.length * colCount * 3);
|
||||
let cursor = 0;
|
||||
rows.forEach((row) => {
|
||||
chainages.push(row.chainage_m);
|
||||
// sections는 프레임마다 전 키를 채우므로(축퇴 포함) 항상 존재한다.
|
||||
const points = row.sections.get(key)!;
|
||||
points.forEach((point) => {
|
||||
const [x, y, z] = toModel(row, point);
|
||||
positions[cursor] = x;
|
||||
positions[cursor + 1] = y;
|
||||
positions[cursor + 2] = z;
|
||||
cursor += 3;
|
||||
});
|
||||
});
|
||||
ribbons.push({ kind, side, colCount, chainages, positions });
|
||||
});
|
||||
|
||||
const outline: CorridorBuildResult["outline"] = { chainages: [], left: [], right: [] };
|
||||
rows.forEach((row) => {
|
||||
outline.chainages.push(row.chainage_m);
|
||||
const [lx, ly] = toModel(row, row.outerLeft);
|
||||
const [rx, ry] = toModel(row, row.outerRight);
|
||||
outline.left.push([lx, ly]);
|
||||
outline.right.push([rx, ry]);
|
||||
});
|
||||
return { ribbons, outline };
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Clip.ts
|
||||
* 원지반 서피스에서 코리도(절·성토 점유) 영역을 **경계 재절단**으로 도려낸다.
|
||||
*
|
||||
* 방식(2026-08-23 사용자 확정 — 정밀): 코리도 외곽(좌우 catch line)을 씬
|
||||
* 수평면(x,z)에 투영한 스트립으로 지형 삼각형을 판정하고,
|
||||
* - 완전 내부 삼각형은 제거,
|
||||
* - 경계에 걸친 삼각형은 catch line 선분으로 실제 절단·재삼각분할 후
|
||||
* 내부 조각만 버린다.
|
||||
* 원본 geometry는 건드리지 않고 클리핑본을 새로 만든다 — [예상형상] 토글
|
||||
* OFF 시 원본 완전체가 다시 보여야 하기 때문(두 벌 유지·스왑).
|
||||
*
|
||||
* 스트립 내부 판정은 프레임 셀(사각형) 단위라 급곡선에서 외곽 폴리곤이
|
||||
* 자기교차해도 견고하다. 정점색(color) 속성은 절단 시 선형 보간해 유지한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { ModelBounds } from "./B05_Profile_UI_Markers";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
interface P2 {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/** 절단 대상 삼각형 — 씬 좌표 정점 3개와 (있으면) 정점색. */
|
||||
interface Tri {
|
||||
px: Float64Array; // [x0,y0,z0, x1,y1,z1, x2,y2,z2]
|
||||
color: Float64Array | null; // [r0,g0,b0, ...]
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
function cross2(ax: number, az: number, bx: number, bz: number): number {
|
||||
return ax * bz - az * bx;
|
||||
}
|
||||
|
||||
function pointInTri2(p: P2, a: P2, b: P2, c: P2): boolean {
|
||||
const d1 = cross2(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z);
|
||||
const d2 = cross2(c.x - b.x, c.z - b.z, p.x - b.x, p.z - b.z);
|
||||
const d3 = cross2(a.x - c.x, a.z - c.z, p.x - c.x, p.z - c.z);
|
||||
const hasNeg = d1 < -EPS || d2 < -EPS || d3 < -EPS;
|
||||
const hasPos = d1 > EPS || d2 > EPS || d3 > EPS;
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
function segmentsIntersect(a: P2, b: P2, c: P2, d: P2): boolean {
|
||||
const d1 = cross2(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z);
|
||||
const d2 = cross2(b.x - a.x, b.z - a.z, d.x - a.x, d.z - a.z);
|
||||
const d3 = cross2(d.x - c.x, d.z - c.z, a.x - c.x, a.z - c.z);
|
||||
const d4 = cross2(d.x - c.x, d.z - c.z, b.x - c.x, b.z - c.z);
|
||||
return d1 * d2 < -EPS && d3 * d4 < -EPS;
|
||||
}
|
||||
|
||||
/** 코리도 스트립 — 셀(프레임 사각형) 격자와 경계 선분 목록. */
|
||||
export class CorridorStrip {
|
||||
private cells: Array<{
|
||||
a: P2;
|
||||
b: P2;
|
||||
c: P2;
|
||||
d: P2;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
}> = [];
|
||||
private segments: Array<{
|
||||
p: P2;
|
||||
q: P2;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
}> = [];
|
||||
readonly minX: number;
|
||||
readonly maxX: number;
|
||||
readonly minZ: number;
|
||||
readonly maxZ: number;
|
||||
|
||||
constructor(build: CorridorBuildResult, bounds: ModelBounds) {
|
||||
const cx = (bounds.x[0] + bounds.x[1]) / 2;
|
||||
const cy = (bounds.y[0] + bounds.y[1]) / 2;
|
||||
const toScene = ([mx, my]: [number, number]): P2 => ({ x: mx - cx, z: -(my - cy) });
|
||||
const left = build.outline.left.map(toScene);
|
||||
const right = build.outline.right.map(toScene);
|
||||
const count = Math.min(left.length, right.length);
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
const touch = (p: P2): void => {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.z < minZ) minZ = p.z;
|
||||
if (p.z > maxZ) maxZ = p.z;
|
||||
};
|
||||
for (let i = 0; i < count - 1; i += 1) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
const c = right[i + 1];
|
||||
const d = left[i + 1];
|
||||
[a, b, c, d].forEach(touch);
|
||||
this.cells.push({
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
d,
|
||||
minX: Math.min(a.x, b.x, c.x, d.x),
|
||||
maxX: Math.max(a.x, b.x, c.x, d.x),
|
||||
minZ: Math.min(a.z, b.z, c.z, d.z),
|
||||
maxZ: Math.max(a.z, b.z, c.z, d.z),
|
||||
});
|
||||
}
|
||||
const addSegment = (p: P2, q: P2): void => {
|
||||
this.segments.push({
|
||||
p,
|
||||
q,
|
||||
minX: Math.min(p.x, q.x),
|
||||
maxX: Math.max(p.x, q.x),
|
||||
minZ: Math.min(p.z, q.z),
|
||||
maxZ: Math.max(p.z, q.z),
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < count - 1; i += 1) {
|
||||
addSegment(left[i], left[i + 1]);
|
||||
addSegment(right[i], right[i + 1]);
|
||||
}
|
||||
if (count > 0) {
|
||||
addSegment(left[0], right[0]);
|
||||
addSegment(left[count - 1], right[count - 1]);
|
||||
}
|
||||
this.minX = minX;
|
||||
this.maxX = maxX;
|
||||
this.minZ = minZ;
|
||||
this.maxZ = maxZ;
|
||||
}
|
||||
|
||||
contains(p: P2): boolean {
|
||||
for (const cell of this.cells) {
|
||||
if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue;
|
||||
if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보. */
|
||||
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number) {
|
||||
return this.segments.filter(
|
||||
(s) => s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 삼각형을 선분의 무한직선으로 절단해 소삼각형 목록으로 — 실교차 시에만 호출. */
|
||||
function splitTriByLine(tri: Tri, p: P2, q: P2): Tri[] {
|
||||
const dir = { x: q.x - p.x, z: q.z - p.z };
|
||||
const dist = (x: number, z: number): number => cross2(dir.x, dir.z, x - p.x, z - p.z);
|
||||
const d = [dist(tri.px[0], tri.px[2]), dist(tri.px[3], tri.px[5]), dist(tri.px[6], tri.px[8])];
|
||||
const pos: number[] = [];
|
||||
const neg: number[] = [];
|
||||
d.forEach((value, i) => (value >= 0 ? pos.push(i) : neg.push(i)));
|
||||
if (pos.length === 0 || neg.length === 0) return [tri];
|
||||
|
||||
// 정점 i·j 사이 직선 교차점의 보간 파라미터.
|
||||
const lerpVertex = (i: number, j: number): { p: number[]; c: number[] | null } => {
|
||||
const t = d[i] / (d[i] - d[j]);
|
||||
const point = [0, 1, 2].map(
|
||||
(axis) => tri.px[i * 3 + axis] + (tri.px[j * 3 + axis] - tri.px[i * 3 + axis]) * t,
|
||||
);
|
||||
const color = tri.color
|
||||
? [0, 1, 2].map(
|
||||
(axis) =>
|
||||
tri.color![i * 3 + axis] + (tri.color![j * 3 + axis] - tri.color![i * 3 + axis]) * t,
|
||||
)
|
||||
: null;
|
||||
return { p: point, c: color };
|
||||
};
|
||||
const vertexOf = (i: number): { p: number[]; c: number[] | null } => ({
|
||||
p: [tri.px[i * 3], tri.px[i * 3 + 1], tri.px[i * 3 + 2]],
|
||||
c: tri.color ? [tri.color[i * 3], tri.color[i * 3 + 1], tri.color[i * 3 + 2]] : null,
|
||||
});
|
||||
const makeTri = (a: { p: number[]; c: number[] | null }, b: typeof a, c: typeof a): Tri => ({
|
||||
px: Float64Array.from([...a.p, ...b.p, ...c.p]),
|
||||
color: a.c && b.c && c.c ? Float64Array.from([...a.c, ...b.c, ...c.c]) : null,
|
||||
});
|
||||
|
||||
// 한쪽 1개 / 반대쪽 2개 — 교차점 2개로 삼각형 3개.
|
||||
const lone = pos.length === 1 ? pos[0] : neg[0];
|
||||
const pair = pos.length === 1 ? neg : pos;
|
||||
const i0 = lerpVertex(lone, pair[0]);
|
||||
const i1 = lerpVertex(lone, pair[1]);
|
||||
return [
|
||||
makeTri(vertexOf(lone), i0, i1),
|
||||
makeTri(vertexOf(pair[0]), vertexOf(pair[1]), i0),
|
||||
makeTri(vertexOf(pair[1]), i1, i0),
|
||||
];
|
||||
}
|
||||
|
||||
function triCentroid(tri: Tri): P2 {
|
||||
return {
|
||||
x: (tri.px[0] + tri.px[3] + tri.px[6]) / 3,
|
||||
z: (tri.px[2] + tri.px[5] + tri.px[8]) / 3,
|
||||
};
|
||||
}
|
||||
|
||||
/** 세그먼트가 삼각형과 실제로 교차하는가(끝점 포함) — 과절단 방지 가드. */
|
||||
function segmentTouchesTri(tri: Tri, p: P2, q: P2): boolean {
|
||||
const a: P2 = { x: tri.px[0], z: tri.px[2] };
|
||||
const b: P2 = { x: tri.px[3], z: tri.px[5] };
|
||||
const c: P2 = { x: tri.px[6], z: tri.px[8] };
|
||||
if (pointInTri2(p, a, b, c) || pointInTri2(q, a, b, c)) return true;
|
||||
return (
|
||||
segmentsIntersect(p, q, a, b) || segmentsIntersect(p, q, b, c) || segmentsIntersect(p, q, c, a)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 지형 BufferGeometry 하나를 스트립으로 재절단한다.
|
||||
* 반환: 스트립 밖 조각만 남긴 non-indexed geometry (변화 없으면 null).
|
||||
*/
|
||||
export function clipGeometry(
|
||||
geometry: THREE.BufferGeometry,
|
||||
strip: CorridorStrip,
|
||||
): THREE.BufferGeometry | null {
|
||||
const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||
if (!position) return null;
|
||||
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
const index = geometry.getIndex();
|
||||
const triCount = index ? index.count / 3 : position.count / 3;
|
||||
|
||||
const outPositions: number[] = [];
|
||||
const outColors: number[] = [];
|
||||
let changed = false;
|
||||
|
||||
const vertexAt = (i: number): number[] => [position.getX(i), position.getY(i), position.getZ(i)];
|
||||
const colorAt = (i: number): number[] | null =>
|
||||
color ? [color.getX(i), color.getY(i), color.getZ(i)] : null;
|
||||
const emit = (tri: Tri): void => {
|
||||
for (let v = 0; v < 3; v += 1) {
|
||||
outPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]);
|
||||
if (tri.color) outColors.push(tri.color[v * 3], tri.color[v * 3 + 1], tri.color[v * 3 + 2]);
|
||||
}
|
||||
};
|
||||
|
||||
for (let t = 0; t < triCount; t += 1) {
|
||||
const ia = index ? index.getX(t * 3) : t * 3;
|
||||
const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1;
|
||||
const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2;
|
||||
const pa = vertexAt(ia);
|
||||
const pb = vertexAt(ib);
|
||||
const pc = vertexAt(ic);
|
||||
const minX = Math.min(pa[0], pb[0], pc[0]);
|
||||
const maxX = Math.max(pa[0], pb[0], pc[0]);
|
||||
const minZ = Math.min(pa[2], pb[2], pc[2]);
|
||||
const maxZ = Math.max(pa[2], pb[2], pc[2]);
|
||||
const tri: Tri = {
|
||||
px: Float64Array.from([...pa, ...pb, ...pc]),
|
||||
color: color ? Float64Array.from([...colorAt(ia)!, ...colorAt(ib)!, ...colorAt(ic)!]) : null,
|
||||
};
|
||||
// 코리도 전체 AABB 밖 — 그대로 유지.
|
||||
if (maxX < strip.minX || minX > strip.maxX || maxZ < strip.minZ || minZ > strip.maxZ) {
|
||||
emit(tri);
|
||||
continue;
|
||||
}
|
||||
const near = strip.segmentsNear(minX, maxX, minZ, maxZ);
|
||||
if (near.length === 0) {
|
||||
// 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는
|
||||
// 삼각형은 안/밖 어느 한쪽에 통째로 있다).
|
||||
if (strip.contains(triCentroid(tri))) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
emit(tri);
|
||||
continue;
|
||||
}
|
||||
// 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정.
|
||||
let fragments: Tri[] = [tri];
|
||||
for (const segment of near) {
|
||||
const next: Tri[] = [];
|
||||
for (const fragment of fragments) {
|
||||
if (segmentTouchesTri(fragment, segment.p, segment.q)) {
|
||||
next.push(...splitTriByLine(fragment, segment.p, segment.q));
|
||||
} else {
|
||||
next.push(fragment);
|
||||
}
|
||||
}
|
||||
fragments = next;
|
||||
}
|
||||
const kept = fragments.filter((fragment) => !strip.contains(triCentroid(fragment)));
|
||||
if (kept.length === fragments.length) {
|
||||
emit(tri); // 절단은 됐지만 전부 밖 — 원본 그대로 유지(조각 수 증가 방지).
|
||||
} else {
|
||||
changed = true;
|
||||
kept.forEach(emit);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
const clipped = new THREE.BufferGeometry();
|
||||
clipped.setAttribute("position", new THREE.Float32BufferAttribute(outPositions, 3));
|
||||
if (color && outColors.length) {
|
||||
clipped.setAttribute("color", new THREE.Float32BufferAttribute(outColors, 3));
|
||||
}
|
||||
clipped.computeVertexNormals();
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지형 전체(Object3D 트리)의 클리핑본을 만든다 — 원본은 불변.
|
||||
* Mesh는 재절단, Points(meshfree)는 스트립 내부 점 제거. 재질은 clone해
|
||||
* 원본과 dispose 수명을 분리한다.
|
||||
*/
|
||||
export function clipTerrain(
|
||||
terrain: THREE.Object3D,
|
||||
build: CorridorBuildResult,
|
||||
bounds: ModelBounds,
|
||||
): THREE.Object3D {
|
||||
const strip = new CorridorStrip(build, bounds);
|
||||
const root = new THREE.Group();
|
||||
root.name = "terrain-clipped";
|
||||
terrain.updateMatrixWorld(true);
|
||||
terrain.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const source = child.geometry.clone().applyMatrix4(child.matrixWorld);
|
||||
const clipped = clipGeometry(source, strip) ?? source;
|
||||
if (clipped !== source) source.dispose();
|
||||
const material = Array.isArray(child.material)
|
||||
? child.material.map((m) => m.clone())
|
||||
: child.material.clone();
|
||||
root.add(new THREE.Mesh(clipped, material));
|
||||
} else if (child instanceof THREE.Points) {
|
||||
const source = child.geometry.clone().applyMatrix4(child.matrixWorld);
|
||||
const position = source.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||
if (!position) return;
|
||||
const keep: number[] = [];
|
||||
for (let i = 0; i < position.count; i += 1) {
|
||||
if (!strip.contains({ x: position.getX(i), z: position.getZ(i) })) keep.push(i);
|
||||
}
|
||||
const positions = new Float32Array(keep.length * 3);
|
||||
keep.forEach((src, dst) => {
|
||||
positions[dst * 3] = position.getX(src);
|
||||
positions[dst * 3 + 1] = position.getY(src);
|
||||
positions[dst * 3 + 2] = position.getZ(src);
|
||||
});
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const color = source.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
if (color) {
|
||||
const colors = new Float32Array(keep.length * 3);
|
||||
keep.forEach((src, dst) => {
|
||||
colors[dst * 3] = color.getX(src);
|
||||
colors[dst * 3 + 1] = color.getY(src);
|
||||
colors[dst * 3 + 2] = color.getZ(src);
|
||||
});
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
}
|
||||
source.dispose();
|
||||
root.add(new THREE.Points(geometry, (child.material as THREE.Material).clone()));
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Mesh.ts
|
||||
* 코리도 리본(모델 좌표 격자) → Three.js 메쉬 그룹.
|
||||
*
|
||||
* 종류별 색으로 구분된 멀티 서피스 조합(2026-08-23 계획) — 하나의 서피스가
|
||||
* 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다.
|
||||
* 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로
|
||||
* 변환해 담는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { ModelBounds } from "./B05_Profile_UI_Markers";
|
||||
import type {
|
||||
CorridorBuildResult,
|
||||
CorridorKind,
|
||||
CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
/** 종류별 표시 색 — 도면 관례(절토 적갈·성토 녹색), 검증 후 사용자 조정 여지. */
|
||||
const KIND_COLORS: Record<CorridorKind, number> = {
|
||||
carriageway: 0x8b8f98,
|
||||
shoulder: 0xb8bcc4,
|
||||
ditch: 0x3b82f6,
|
||||
cut: 0xc2703d,
|
||||
fill: 0x4f9d4f,
|
||||
};
|
||||
|
||||
/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */
|
||||
const POLYGON_OFFSET_FACTOR = -1;
|
||||
|
||||
function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.BufferGeometry {
|
||||
const rowCount = ribbon.chainages.length;
|
||||
const cols = ribbon.colCount;
|
||||
const cx = (bounds.x[0] + bounds.x[1]) / 2;
|
||||
const cy = (bounds.y[0] + bounds.y[1]) / 2;
|
||||
const cz = (bounds.z[0] + bounds.z[1]) / 2;
|
||||
const positions = new Float32Array(rowCount * cols * 3);
|
||||
for (let i = 0; i < rowCount * cols; i += 1) {
|
||||
const x = ribbon.positions[i * 3];
|
||||
const y = ribbon.positions[i * 3 + 1];
|
||||
const z = ribbon.positions[i * 3 + 2];
|
||||
positions[i * 3] = x - cx;
|
||||
positions[i * 3 + 1] = z - cz;
|
||||
positions[i * 3 + 2] = -(y - cy);
|
||||
}
|
||||
const indices: number[] = [];
|
||||
for (let row = 0; row < rowCount - 1; row += 1) {
|
||||
for (let col = 0; col < cols - 1; col += 1) {
|
||||
const a = row * cols + col;
|
||||
const b = a + 1;
|
||||
const c = a + cols;
|
||||
const d = c + 1;
|
||||
indices.push(a, c, b, b, c, d);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */
|
||||
export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group {
|
||||
const group = new THREE.Group();
|
||||
group.name = "corridor";
|
||||
build.ribbons.forEach((ribbon) => {
|
||||
if (ribbon.chainages.length < 2) return;
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: KIND_COLORS[ribbon.kind],
|
||||
side: THREE.DoubleSide,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: POLYGON_OFFSET_FACTOR,
|
||||
polygonOffsetUnits: POLYGON_OFFSET_FACTOR,
|
||||
});
|
||||
const mesh = new THREE.Mesh(ribbonGeometry(ribbon, bounds), material);
|
||||
mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`;
|
||||
group.add(mesh);
|
||||
});
|
||||
return group;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
loadSectionDetail,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
|
||||
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
||||
import "./B05_Profile_UI_Style.css";
|
||||
import "./B05_Profile_UI_Style_Structures.css";
|
||||
import {
|
||||
@@ -233,10 +234,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const panel = createRoutePanel({
|
||||
onSolve: () => void solve(),
|
||||
onTempSave: () => void tempSave(),
|
||||
onGoCross: () => navigateTo(ROUTES.B06_SECTION),
|
||||
onGoCross: () => {
|
||||
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
navigateTo(ROUTES.B06_SECTION);
|
||||
},
|
||||
onReset: () => void resetDesign(),
|
||||
onContourApply: (interval) => void applyContours(interval),
|
||||
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||
onCorridorVisible: viewer.setCorridorVisible,
|
||||
onContoursVisible: viewer.setContoursVisible,
|
||||
onAxesVisible: viewer.setAxesVisible,
|
||||
onStationLinesVisible: viewer.setStationLinesVisible,
|
||||
@@ -396,6 +402,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
profilePanel.setStationDisplay(panel.stationDisplayOffset());
|
||||
profilePanel.setIrregularStations(bridge.irregularStations());
|
||||
renderStationLines(detail);
|
||||
// 예상형상 코리도 — 현재 종횡단 정본 그대로 빌드/로드해 3D에 반영(2026-08-23).
|
||||
// 노선 폴리라인이 아직 없으면 미룬다 — 세분 기준이 없고, 로드 순서상
|
||||
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
|
||||
const routePoints = latest?.route_points ?? [];
|
||||
if (routePoints.length > 1) {
|
||||
refreshCorridor(viewer, activeProjectId, routeId ?? latest?.route?.id, detail, routePoints);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSections(routeId: number): Promise<void> {
|
||||
@@ -579,6 +592,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
false,
|
||||
);
|
||||
renderLatest(await loadLatest(true));
|
||||
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
|
||||
showToast(L("B05_Route_TempSave_Success"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
||||
|
||||
@@ -91,6 +91,8 @@ interface PanelCallbacks {
|
||||
onReset: () => void;
|
||||
onContourApply: (interval: number) => void;
|
||||
onSurfaceVisible: (visible: boolean) => void;
|
||||
/** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */
|
||||
onCorridorVisible: (visible: boolean) => void;
|
||||
onContoursVisible: (visible: boolean) => void;
|
||||
onAxesVisible: (visible: boolean) => void;
|
||||
onStationLinesVisible: (visible: boolean) => void;
|
||||
@@ -214,6 +216,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
visibilityButtons.className = "b05-route__view-group";
|
||||
visibilityButtons.append(
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type RoutePointKind,
|
||||
type SectionStationMarker,
|
||||
} from "./B05_Profile_UI_Markers";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
|
||||
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
|
||||
|
||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
|
||||
const DARK_VIEWER_BACKGROUND = 0x251f38;
|
||||
@@ -52,6 +55,14 @@ export interface RouteViewer {
|
||||
setStationLinesVisible: (visible: boolean) => void;
|
||||
/** 구조물(비정규) 측점의 번호·이름 라벨 표시 토글. */
|
||||
setStationLabelsVisible: (visible: boolean) => void;
|
||||
/**
|
||||
* 계획노선 코리도(예상형상) 서피스 반영. build의 비탈 최외곽 정점 z는 지형
|
||||
* 메쉬에 투영(심 보정)되므로 **전달 객체가 제자리 수정**된다 — 저장 직렬화는
|
||||
* 이 호출 뒤에 할 것. null이면 코리도 제거.
|
||||
*/
|
||||
setCorridor: (build: CorridorBuildResult | null) => void;
|
||||
/** [예상형상] 토글 — ON: 클리핑 지형+코리도(공사 후), OFF: 원본 지형 완전체. */
|
||||
setCorridorVisible: (visible: boolean) => void;
|
||||
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
|
||||
setView: (view: "iso" | "top" | "front" | "side") => void;
|
||||
beginMoveSelected: () => void;
|
||||
@@ -111,6 +122,12 @@ export function createRouteViewer(): RouteViewer {
|
||||
scene.add(axes);
|
||||
|
||||
let terrain: THREE.Object3D | null = null;
|
||||
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
|
||||
let corridorBuild: CorridorBuildResult | null = null;
|
||||
let corridorGroup: THREE.Group | null = null;
|
||||
let clippedTerrain: THREE.Object3D | null = null;
|
||||
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
|
||||
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
|
||||
const contours = new THREE.Group();
|
||||
scene.add(contours);
|
||||
let bounds: ModelBounds | null = null;
|
||||
@@ -361,7 +378,10 @@ export function createRouteViewer(): RouteViewer {
|
||||
// (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
|
||||
let surfaceGrayscale = false;
|
||||
function applySurfaceGrayscale(): void {
|
||||
terrain?.traverse((child) => {
|
||||
[terrain, clippedTerrain].forEach((target) => applyGrayscaleTo(target));
|
||||
}
|
||||
function applyGrayscaleTo(target: THREE.Object3D | null): void {
|
||||
target?.traverse((child) => {
|
||||
const geometry = (child as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
|
||||
const color = geometry?.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
if (!geometry || !color) return;
|
||||
@@ -381,6 +401,84 @@ export function createRouteViewer(): RouteViewer {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 예상형상(코리도) — 원본/클리핑 지형 스왑 + 계획 서피스 조합 ───────── */
|
||||
|
||||
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
|
||||
function applyCorridorVisibility(): void {
|
||||
const swapped = corridorOn && clippedTerrain !== null;
|
||||
if (terrain) terrain.visible = surfaceOn && !swapped;
|
||||
if (clippedTerrain) clippedTerrain.visible = surfaceOn && swapped;
|
||||
if (corridorGroup) corridorGroup.visible = corridorOn;
|
||||
}
|
||||
|
||||
function disposeClippedTerrain(): void {
|
||||
if (!clippedTerrain) return;
|
||||
scene.remove(clippedTerrain);
|
||||
disposeObject(clippedTerrain);
|
||||
clippedTerrain = null;
|
||||
}
|
||||
|
||||
function disposeCorridorGroup(): void {
|
||||
if (!corridorGroup) return;
|
||||
scene.remove(corridorGroup);
|
||||
disposeObject(corridorGroup);
|
||||
corridorGroup = null;
|
||||
}
|
||||
|
||||
/** 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. */
|
||||
function snapCorridorEdges(build: CorridorBuildResult): void {
|
||||
build.ribbons.forEach((ribbon) => {
|
||||
if (ribbon.kind !== "cut" && ribbon.kind !== "fill" && ribbon.kind !== "ditch") return;
|
||||
const cols = ribbon.colCount;
|
||||
const outerCol = ribbon.side === "right" ? 0 : cols - 1;
|
||||
const innerCol = ribbon.side === "right" ? cols - 1 : 0;
|
||||
for (let row = 0; row < ribbon.chainages.length; row += 1) {
|
||||
const outer = (row * cols + outerCol) * 3;
|
||||
const inner = (row * cols + innerCol) * 3;
|
||||
// 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다.
|
||||
const width = Math.hypot(
|
||||
ribbon.positions[outer] - ribbon.positions[inner],
|
||||
ribbon.positions[outer + 1] - ribbon.positions[inner + 1],
|
||||
);
|
||||
if (width < 1e-4) continue;
|
||||
const ground = terrainElevation(ribbon.positions[outer], ribbon.positions[outer + 1]);
|
||||
if (ground !== null) ribbon.positions[outer + 2] = ground;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
|
||||
function scheduleTerrainClip(): void {
|
||||
disposeClippedTerrain();
|
||||
if (!corridorBuild || !terrain || !bounds) {
|
||||
applyCorridorVisibility();
|
||||
return;
|
||||
}
|
||||
const buildAtSchedule = corridorBuild;
|
||||
requestAnimationFrame(() => {
|
||||
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
|
||||
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
|
||||
const clipped = clipTerrain(terrain, buildAtSchedule, bounds);
|
||||
disposeClippedTerrain();
|
||||
clippedTerrain = clipped;
|
||||
scene.add(clipped);
|
||||
applyGrayscaleTo(clipped); // 흑백 토글 상태 유지.
|
||||
applyCorridorVisibility();
|
||||
});
|
||||
applyCorridorVisibility();
|
||||
}
|
||||
|
||||
function setCorridor(build: CorridorBuildResult | null): void {
|
||||
disposeCorridorGroup();
|
||||
corridorBuild = build;
|
||||
if (build && bounds) {
|
||||
snapCorridorEdges(build);
|
||||
corridorGroup = createCorridorGroup(build, bounds);
|
||||
scene.add(corridorGroup);
|
||||
}
|
||||
scheduleTerrainClip();
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
markers,
|
||||
@@ -408,6 +506,9 @@ export function createRouteViewer(): RouteViewer {
|
||||
scene.add(terrain);
|
||||
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
|
||||
applySurfaceGrayscale();
|
||||
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
|
||||
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
|
||||
if (corridorBuild) setCorridor(corridorBuild);
|
||||
fit("top");
|
||||
markers.renderMarkers();
|
||||
await reloadContours(interval);
|
||||
@@ -417,7 +518,13 @@ export function createRouteViewer(): RouteViewer {
|
||||
},
|
||||
reloadContours,
|
||||
setSurfaceVisible(visible) {
|
||||
if (terrain) terrain.visible = visible;
|
||||
surfaceOn = visible;
|
||||
applyCorridorVisibility();
|
||||
},
|
||||
setCorridor,
|
||||
setCorridorVisible(visible) {
|
||||
corridorOn = visible;
|
||||
applyCorridorVisibility();
|
||||
},
|
||||
setSurfaceGrayscale(grayscale) {
|
||||
if (surfaceGrayscale === grayscale) return;
|
||||
@@ -453,6 +560,9 @@ export function createRouteViewer(): RouteViewer {
|
||||
markers.dispose();
|
||||
clearContours();
|
||||
disposeObject(terrain);
|
||||
corridorBuild = null; // 예약된 클립 콜백 무효화.
|
||||
disposeCorridorGroup();
|
||||
disposeClippedTerrain();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ from B04_PreProcess.B04_PreProcess_Router_GIS import tiles_router
|
||||
from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_router
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router
|
||||
from B05_Profile.B05_Profile_Router import router as b05_route_router
|
||||
from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router
|
||||
from B06_Section.B06_Section_Router import router as b06_section_router
|
||||
from B06_Section.B06_Section_Router_Confirm import (
|
||||
@@ -377,6 +378,7 @@ app.include_router(b04_inflow_router, dependencies=protected_with_company)
|
||||
app.include_router(b04_basins_router, dependencies=protected_with_company)
|
||||
app.include_router(tiles_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_route_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_corridor_router, dependencies=protected_with_company)
|
||||
app.include_router(b05_structures_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
|
||||
Reference in New Issue
Block a user