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:
2026-08-23 12:28:37 +09:00
co-authored by Claude Opus 5
parent 30031c33c0
commit 3b997963e3
9 changed files with 1355 additions and 3 deletions
+100
View File
@@ -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": "코리도 저장에 실패했습니다."}
)
+255
View File
@@ -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 };
}
+364
View File
@@ -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;
}
+16 -1
View File
@@ -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");
+4
View File
@@ -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),
+112 -2
View File
@@ -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();
},
+2
View File
@@ -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)