diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py new file mode 100644 index 00000000..43ad92e1 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -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": "코리도 저장에 실패했습니다."} + ) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts new file mode 100644 index 00000000..54f56408 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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(); + +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 = [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 { + 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 { + 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 { + 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 { + 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 { + 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)); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts new file mode 100644 index 00000000..2dcbdef0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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 = { + 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; + /** 축퇴 기준점 — 좌/우 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(); + 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(); + stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key))); + + const rows: Array<{ + chainage_m: number; + center: XY; + left: XY; + sections: Map; + 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(); + 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 }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts new file mode 100644 index 00000000..49d5a8e0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -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; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts new file mode 100644 index 00000000..e31f3922 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -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 = { + 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; +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 1e330efe..35a60872 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { 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 { 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 { @@ -579,6 +592,8 @@ export async function renderB05Route(root: HTMLElement): Promise { 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"); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 4d46e298..baa986db 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -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), diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 0698f41f..95728e30 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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(); }, diff --git a/main.py b/main.py index b70bbd92..031f5735 100644 --- a/main.py +++ b/main.py @@ -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)