diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor.ts b/B05_Profile/Old/B05_Profile_UI_Corridor.ts deleted file mode 100644 index 0f4545232..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor.ts +++ /dev/null @@ -1,292 +0,0 @@ -/* ============================================================================= - * 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 { - purgeAssetsWithPrefix, - readCachedBytes, - writeCachedBytes, -} from "../A00_Common/b_asset_cache"; -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { - corridorHash, - ENVELOPE_VERSION, - deserialize, - serialize, - type CorridorEnvelope, -} from "./B05_Profile_UI_Corridor_Envelope"; -import { rememberLegacyInputs } from "./B05_Profile_UI_Corridor_Legacy"; - -/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */ -export interface CorridorStructureHook { - /** 구조물이 점유한 측점 구간 [start, end] (chainage m) — 이 구간 리본을 대체한다. */ - applies: (chainageStart: number, chainageEnd: number) => boolean; -} - -interface CacheEntry { - hash: string; - build: CorridorBuildResult; - dirty: boolean; -} - -const cache = new Map(); - -function keyOf(projectId: string, routeId: number): string { - return `${projectId}:${routeId}`; -} - -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); - } -} - -/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */ -type StoredLookup = - { kind: "envelope"; envelope: CorridorEnvelope } | { kind: "stale" } | { kind: "missing" }; - -/** - * 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면 - * 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤 - * 해시가 다르다고 버렸다. - */ -/** 이 노선 코리도의 보관 주소 앞머리 — 열쇠만 다른 옛 보관본을 치울 때 쓴다. */ -function corridorUrlPrefix(projectId: string, routeId: number): string { - return `${API_BASE_URL}/projects/${projectId}/routes/${routeId}/corridor`; -} - -async function fetchStored( - projectId: string, - routeId: number, - hash: string, -): Promise { - const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`; - try { - // **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번 - // 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가 - // 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다. - const cached = await readCachedBytes(projectId, url); - if (cached) { - const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope; - if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { - return { kind: "envelope", envelope: payload }; - } - } - const response = await requestCorridor( - `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, - { method: "GET" }, - ); - if (!response.ok) return { kind: "missing" }; - const text = await response.text(); - const payload = JSON.parse(text) as CorridorEnvelope & { status?: string }; - if (payload?.status === "stale") return { kind: "stale" }; - if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { - // 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게. - void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() => - writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer), - ); - return { kind: "envelope", envelope: payload }; - } - return { kind: "missing" }; - } catch { - return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. - } -} - -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 시점의 최신 상태를 담는다). - */ -/** 종단 계획선 샘플(라이브 편집 반영분) — buildCorridor designSamples와 같은 꼴. */ -export type ProfileSamples = Array<{ - chainage_m: number; - elevation_m: number; - ground_elevation_m?: number; -}>; - -/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나. - * 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다 - * (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */ -declare global { - interface Window { - __corridorSource?: { source: "stored" | "built"; hash: string; at: number }; - } -} - -function markSource(source: "stored" | "built", hash: string): void { - window.__corridorSource = { source, hash, at: Date.now() }; -} - -export async function ensureCorridor( - projectId: string, - routeId: number, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, - /** 저장본이 낡았을 때 **브라우저가 다시 만들지** 여부. 진입 경로는 false 로 부른다 — - * 3D 는 [3D 업데이트]로만 도는 수동 조작인데 진입만 자동으로 남아 있었다(2026-09-06). */ - rebuild = true, -): 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 lookup = await fetchStored(projectId, routeId, hash); - if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { - try { - const build = deserialize(lookup.envelope); - cache.set(key, { hash, build, dirty: false }); - markSource("stored", hash); - return build; - } catch { - // 손상 저장본 — 빌드로 폴백. - } - } - // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 - // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). - if (!rebuild) return null; - // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 - // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. - const hasStored = lookup.kind !== "missing"; - - // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. - // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 - // 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③). - const build = buildCorridor( - detail.cross_sections, - routePoints, - designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, - ); - if (!build) { - cache.delete(key); - return null; - } - markSource("built", hash); - if (!hasStored) { - // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). - 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 envelope = serialize(entry.build, entry.hash); - const ok = await putStored(projectId, routeId, envelope); - if (!ok) return; - entry.dirty = false; - // 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가 - // 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다. - const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`; - const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer; - await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)); - await writeCachedBytes(projectId, url, bytes); -} - -/** 새 방식 지형 변형이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). - * 옛 방식 A(115-4) 입력도 함께 묶는다 — 단추를 켤 때 즉석 빌드용(메모리만). */ -function withSections( - build: CorridorBuildResult | null, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, -): CorridorBuildResult | null { - if (build) { - build.sections = detail.cross_sections; - rememberLegacyInputs(build, { detail, routePoints, designSamples }); - } - return build; -} - -/** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). - * 끝나는 시점을 [3D 업데이트] 버튼이 기다려야 해 Promise를 돌려준다(2026-09-01). */ -export function refreshCorridor( - viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, - projectId: string, - routeId: number | undefined, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, - rebuild = true, -): Promise { - if (!routeId) return Promise.resolve(); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) - .then((build) => viewer.setCorridor(withSections(build, detail, routePoints, designSamples))) - .catch(() => viewer.setCorridor(null)); -} - -/** - * 진입 전용 — 저장본이 **그대로 맞을 때만** 3D 에 올린다. 낡았으면 받지도 만들지도 않고 - * `false` 를 돌려주므로, 부르는 쪽이 [3D 업데이트] 대기 표시를 켜면 된다(2026-09-06). - * 돌아온 값이 곧 「지금 화면의 3D 가 최신인가」다. - */ -export function loadCorridorIfFresh( - viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, - projectId: string, - routeId: number | undefined, - detail: SectionDetailResponse, - routePoints: RoutePoint[], - designSamples?: ProfileSamples, -): Promise { - if (!routeId) return Promise.resolve(false); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) - .then((build) => { - if (build) viewer.setCorridor(withSections(build, detail, routePoints, designSamples)); - return build !== null; - }) - .catch(() => false); -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts deleted file mode 100644 index 5b8082bc3..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Build.ts +++ /dev/null @@ -1,635 +0,0 @@ -/* ============================================================================= - * 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"; -import { - classifyStation, - mixedGround, - PIECE_COLS, - pieceKey, - resample, - trimSlopeToGround, - type CorridorKind, - type CorridorSide, - type OffsetPoint, - type StationPieces, - type XY, -} from "./B05_Profile_UI_Corridor_Station"; - -export type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station"; -import { - bracketControls, - buildPieceControls, - buildPolylineSampler, - buildCrossingFinder, - buildProfileSampler, - lerpPoints, - slerpLeft, - type PieceControl, -} from "./B05_Profile_UI_Corridor_Frames"; -import { buildCorridorStructures, type RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; -import type { - CorridorBuildResult, - CorridorCap, - CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build_Types"; -// 결과 자료형은 `_Build_Types` 에 있다(2026-09-02 분리) — 호출부 경로 유지를 위해 재수출. -export type { - CorridorBuildResult, - CorridorCap, - CorridorCutWall, - CorridorRibbon, -} from "./B05_Profile_UI_Corridor_Build_Types"; -import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { buildPlanCurves } from "./B05_Profile_UI_Corridor_Plan"; -import { maskFillByPlanCurves } from "./B05_Profile_UI_Corridor_Cut"; -export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */ -/** - * 구조물 구간 **성토 패치 리본** 적용 여부(2026-08-27 사용자). B06 횡단이 내는 - * 변형 성토선을 3D 포켓에 되메운다 — 다단이면 `성토 > 구조물 > 성토`라 성토선이 - * 하나가 아니므로 **구간 1줄 = 리본 1장**으로 낸다(`_Carve.ts` `fillRuns`). - * - * 절취(`FILL_CUT_APPLY`)와 별개다: 패치는 잘린 자리를 채우기만 하고, 절취·측벽· - * 경계 트림(0-2~0-6)은 그대로 둔다. 패치 리본은 절취 대상이 아니다 - * (`_Cut.ts` `ribbon.patch` 제외). - */ -const FILL_PATCH_APPLY: boolean = true; - -/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). - * 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 — - * 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */ -const SUBDIVIDE_STEP_M = 0.2; - -/** - * 투영 커브 **안쪽 블록**의 종방향 걸음(m) — 2026-08-26 사용자 B안. - * 지금은 바깥과 같은 0.2m라 경계 행만 새로 끼워 넣는 셈이다. 더 촘촘한 A안(0.1m)으로 - * 바꾸려면 이 값만 내리면 된다(사용자: "보고 판단, A로 바꿀 수도 있다"). - */ -const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M; - -/** - * 투영 커브 안쪽 **성토면 도려내기** 적용 여부(2026-08-26 사용자). 끄면 커브만 얹고 - * 서피스는 그대로 둔다. 절토면은 대상이 아니다(사용자: 성토면만). - */ -const FILL_CUT_APPLY: boolean = true; - -/** - * 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영 - * 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은 - * 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시 - * 셀 중간에 뜬다. - */ -interface StructureSpanRange { - side: CorridorSide; - from: number; - to: number; -} - -function structureSpanBounds(crossSections: ReadonlyArray): StructureSpanRange[] { - const spans: StructureSpanRange[] = []; - for (const section of crossSections) { - for (const silhouette of structureSilhouettes(section)) { - spans.push({ - side: silhouette.side, - from: section.chainage_m - silhouette.span.beforeM, - to: section.chainage_m + silhouette.span.afterM, - }); - } - } - return spans; -} - -/** - * 한 측점 구간(s0~s1)의 행 자리(0~1). 등간격 걸음에 **구조물 구간 경계**를 끼워 - * 넣고 정렬·중복 제거한다. 비-마지막 구간은 t=1을 빼는 현행 규칙을 그대로 지킨다 - * (다음 구간의 t=0과 겹치면 두께 0 행이 생긴다). - */ -function rowFractions( - startChainageM: number, - spanM: number, - steps: number, - includeEnd: boolean, - spans: ReadonlyArray, -): number[] { - const ts: number[] = []; - for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps); - const endChainageM = startChainageM + spanM; - const insideStep = Math.max(0.01, STRUCTURE_SPAN_STEP_M); - const put = (chainageM: number): void => { - const t = (chainageM - startChainageM) / spanM; - if (t > 1e-9 && t < 1 - 1e-9) ts.push(t); - }; - for (const { from, to } of spans) { - if (to <= startChainageM || from >= endChainageM) continue; - put(from); - put(to); - // 안쪽 블록만 자기 걸음으로 다시 나눈다. 바깥과 같은 걸음(B안)이면 아래 중복 - // 제거가 전부 걸러 내 정점이 늘지 않는다 — 경계 행만 새로 생긴다. - if (insideStep < SUBDIVIDE_STEP_M - 1e-9) { - for (let at = from + insideStep; at < to - 1e-9; at += insideStep) put(at); - } - } - ts.sort((a, b) => a - b); - // 1mm보다 가까운 행은 합친다 — 겹친 행은 넓이 0 삼각형만 만든다. - const epsilon = spanM > 1e-9 ? 0.001 / spanM : 1; - return ts.filter((t, index) => index === 0 || t - ts[index - 1] > epsilon); -} - -/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */ -/** 종단 보정량 — 도로부(차도·노견·측구)는 계획고에 붙어 있으므로 전량 가산한다. - * 비탈은 상대 좌표라 행 앵커를 통해 보정이 실리고, 끝은 지반 트림이 정한다. */ -function applyProfileShift(points: OffsetPoint[], shift: number): void { - points.forEach((point) => (point.elevation_m += shift)); -} - -/** - * 코리도 빌드 — 측점별 분류 후 노선 폴리라인을 따라 세분·보간해 종류별 리본과 - * 클리핑 외곽선을 만든다. design 있는 측점이 2개 미만이면 null. - */ -export function buildCorridor( - crossSections: CrossSection[], - routePoints: RoutePoint[], - /** - * 종단 계획선 샘플 — 두 가지에 쓴다. - * ① 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함) 따라가게 한다. - * ② 계획고와 지반고가 만나는 자리 = **절토↔성토 전환점**을 찾는다(2026-08-23 - * 사용자 확정): 그 지점이 측구·절토의 종점이고, 거기서부터 성토로 넘어간다. - */ - designSamples?: Array<{ - chainage_m: number; - elevation_m: number; - ground_elevation_m?: number; - }>, - subdivideStepM: number = SUBDIVIDE_STEP_M, - /** - * 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 배수관 세트 측 비탈을 - * 실루엣으로 갈아 끼우고 스케치 · 자르기 · 패치 없이 리본 · 외곽 · 마구리 · 솔리드만 낸다. - */ - options: { legacy?: boolean } = {}, -): CorridorBuildResult | null { - const legacy = options.legacy === true; - const stations = crossSections - .map((section) => classifyStation(section, { silhouette: legacy })) - .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); - // 종단 계획선 보간기 — 없으면 측점 간 직선(구 동작)으로 떨어진다. - const profileZ = buildProfileSampler(designSamples); - // 절토↔성토 전환점 탐색기 — 측구·절토의 종점을 이 자리로 잡는다. - const crossingAt = buildCrossingFinder(designSamples); - // 등장하는 (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; - /** 그 행 지반선 — 성토 리본에 원지반 표고를 함께 실어 절단면 측벽의 밑선으로 쓴다. */ - ground: ((offset: number) => number) | 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, - ]; - - /** - * 조각별 종방향 제어점(2026-08-24 사용자: 구조물 구간 끝을 스무스하게). - * · 노견은 **계획고 기준 상대** 단면 — 행 계획고에 얹어 종단을 그대로 탄다. - * · 절·성토는 **앵커(도로부 바깥 끝) 기준 상대** 단면 — 아래 공통 블록이 얹는다. - */ - const designZ = (chainageM: number): number => profileZ?.(chainageM) ?? 0; - const controlsByKey = new Map(); - (["left", "right"] as const).forEach((side) => { - const shoulderKey = pieceKey("shoulder", side); - const shoulder = buildPieceControls( - stations, - side, - (station) => { - const piece = station.pieces.get(shoulderKey); - if (!piece) return undefined; - const base = designZ(station.chainage_m); - return piece.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m - base, - })); - }, - // 노견 확장(2026-08-25 ①)은 wallSpans 없이도 구간 내내 유지돼야 한다. - (station) => station.shoulderSpans?.[side] ?? station.wallSpans?.[side], - ); - if (shoulder) controlsByKey.set(shoulderKey, shoulder); - (["cut", "fill"] as const).forEach((kind) => { - const slopeKey = pieceKey(kind, side); - const slope = buildPieceControls(stations, side, (station) => - station.slopeRelative.get(slopeKey), - ); - if (slope) controlsByKey.set(slopeKey, slope); - }); - }); - - // 구조물 솔리드·절단 경계를 행 루프보다 먼저 만든다 — 행마다 경계로 비탈을 트림한다(2026-08-25). - // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단과 같은 계산을 그대로 얹는다. - // 스윕 프레임은 코리도 행과 같은 규약(평면 스플라인 + 좌향 slerp)을 쓴다. - const frameAt = (at: number): RouteFrame | null => { - const first = stations[0]; - const last = stations[stations.length - 1]; - const clamped = Math.min(Math.max(at, first.chainage_m), last.chainage_m); - let index = 0; - while (index < stations.length - 2 && stations[index + 1].chainage_m < clamped) index += 1; - const a = stations[index]; - const b = stations[index + 1]; - const span = b.chainage_m - a.chainage_m; - const t = span <= 1e-9 ? 0 : (clamped - a.chainage_m) / span; - const xy = sampler?.(clamped) ?? { - x: a.center.x + (b.center.x - a.center.x) * t, - y: a.center.y + (b.center.y - a.center.y) * t, - }; - const left = slerpLeft(a.left, b.left, t); - return { - cx: xy.x, - cy: xy.y, - leftX: left.x, - leftY: left.y, - designZ: profileZ?.(clamped) ?? null, - }; - }; - const structures = buildCorridorStructures(crossSections, frameAt); - // 탑뷰 투영 커브가 격자 모서리에 앉도록 구조물 구간 경계를 행으로 끼워 넣는다. - // 옛 방식 A 는 그날처럼 등간격 행만 쓴다. - const spanBounds = legacy ? [] : structureSpanBounds(crossSections); - - 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; - // 하한은 0으로 나누는 것만 막는다 — 0.5m로 잡아 두는 바람에 0.1m 지정이 통째로 - // 깎여 종단이 계단처럼 보였다(2026-08-23 사용자 지적, 저장본 702행으로 확인). - const steps = Math.max(1, Math.ceil(span / Math.max(0.01, subdivideStepM))); - const last = i === stations.length - 2; - // 이 구간의 절토↔성토 전환 위치(0~1). 못 찾으면 중간으로 둔다(구 동작). - const crossing = crossingAt?.(s0.chainage_m, s1.chainage_m) ?? null; - const tSwitch = - crossing === null ? 0.5 : Math.min(0.98, Math.max(0.02, (crossing - s0.chainage_m) / span)); - for (const t of rowFractions(s0.chainage_m, span, steps, last, spanBounds)) { - 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 shift = profileZ - ? (profileZ(chainage) ?? 0) - - ((profileZ(s0.chainage_m) ?? 0) + - ((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t) - : 0; - const sections = new Map(); - /** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */ - const fixedSlopeKeys = new Set(); - /** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */ - const fixedSource = new Map(); - /** 제어점 보간으로 이미 계획고에 얹은 조각 — 종단 보정을 두 번 하지 않게 표시. */ - const controlPlaced = new Set(); - keys.forEach((key) => { - const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; - const hasA = s0.pieces.has(key); - const hasB = s1.pieces.has(key); - // 양쪽 다 없으면 그 구간에는 조각 자체가 없다 — 리본을 끊어 없는 자리에 - // 서피스가 생기지 않게 한다(2026-08-23 사용자 지적: 측구 없는 구간). - if (!hasA && !hasB) return; - // 측구는 한쪽에만 있으면 **전환점(계획선-지반선 교차, 없으면 중간)에서 - // 끊는다**(2026-08-23 사용자 확정). 횡단배수 연결 자동 계산이 생기기 - // 전까지의 표현 규칙이다. - let points: OffsetPoint[]; - if (kind === "ditch") { - // 측구는 **도로 끝에 얹는다** — 도로부는 한 몸이므로 위치를 따로 보간하지 - // 않는다(2026-08-23 사용자 지시). 한쪽 측점에만 있으면 그 절반 구간만 - // 그리되, 자리는 항상 그 프레임의 노견 끝을 따라간다. - // 측구 종점 = 절토↔성토 전환점(계획선이 지면선을 통과하는 자리). - if (hasA !== hasB && (hasA ? t > tSwitch : t < tSwitch)) return; - const relA = s0.ditchRelative.get(key); - const relB = s1.ditchRelative.get(key); - const relative = relA && relB ? lerpPoints(relA, relB, t) : (relA ?? relB); - if (!relative) return; - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - const edge = { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t, - }; - points = relative.map((point) => ({ - offset_m: edge.offset_m + point.offset_m, - elevation_m: edge.elevation_m + point.elevation_m, - })); - } else if (kind === "cut" || kind === "fill") { - // 절·성토도 **제어점 보간**이다(2026-08-24 사용자: 확장된 노폭 끝을 스무스하게). - // 상대 좌표라 아래 공통 블록이 행 앵커(측구 끝/노견 끝)에 얹는다. 구조물 - // 구간 안(같은 구간의 두 경계 사이)은 단면이 고정되고 지반 트림을 건너뛴다 — - // 사면이 구조물 상단에서 끝나기 때문. 구간 밖은 이웃 측점까지 테이퍼. - const controls = controlsByKey.get(key); - if (controls) { - const { a, b, t: tc } = bracketControls(controls, chainage); - points = - a === b - ? a.points.map((point) => ({ ...point })) - : lerpPoints(a.points, b.points, tc); - // 구조물 구간 안(같은 구간의 두 경계 사이)이든 경계와 이웃 측점 사이 - // 테이퍼든, 구조물 단면이 섞인 행은 지반 트림을 건너뛴다 — 트림이 성토부선 - // (구조물이 만든 긴 앞치마)을 지반 교차점까지 잘라내 8.1m 절벽이 남았다 - // (2026-08-24 실측 2.23 → 9.88m). - if (a.spanId !== null || b.spanId !== null) fixedSlopeKeys.add(key); - } else { - const relA = s0.slopeRelative.get(key); - const relB = s1.slopeRelative.get(key); - const relative = - relA && relB - ? lerpPoints(relA, relB, t) - : (relA ?? relB)?.map((point) => ({ ...point })); - if (!relative || relative.length < 2) return; - points = relative; - } - } else { - // 노견은 **제어점 보간**으로 그린다(2026-08-24 사용자: 스무스 연결). - // 구조물 구간 안은 두 제어점이 같은 단면이라 그대로 고정되고, 구간 밖은 - // 경계 ↔ 이웃 측점 사이에서 테이퍼가 생긴다. 계획고는 아래에서 얹는다. - const controls = kind === "shoulder" ? controlsByKey.get(key) : undefined; - if (controls) { - const { a, b, t: tc } = bracketControls(controls, chainage); - const relative = - a === b - ? a.points.map((point) => ({ ...point })) - : lerpPoints(a.points, b.points, tc); - const base = designZ(chainage); - points = relative.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + base, - })); - controlPlaced.add(key); - } else { - const pa = s0.pieces.get(key); - const pb = s1.pieces.get(key); - if (!pa || !pb) return; - points = lerpPoints(pa, pb, t); - } - } - // 종단 보정 — 보간 조각은 직선 보간 대비 **곡선 차이만**(shift) 얹으면 되지만, - // 구조물 측점 단면을 그대로 쓴 조각은 그 측점 표고에 얼어붙어 있으므로 - // **원 측점과의 계획고 차이 전량**을 얹어야 한다. 안 그러면 구조물 구간에서 - // 노견만 종단을 안 타고 양 끝에 단이 생긴다(2026-08-24 사용자 지적, 실측 - // 0.49~0.61m 단 / 종단 기울기 10.1%). - const source = fixedSource.get(key); - const rowShift = controlPlaced.has(key) - ? 0 // 제어점 보간은 상대 단면을 행 계획고에 직접 얹는다 — 추가 보정 불필요. - : source !== undefined && profileZ - ? (profileZ(chainage) ?? 0) - (profileZ(source) ?? 0) - : shift; - if (rowShift !== 0 && kind !== "cut" && kind !== "fill") { - applyProfileShift(points, rowShift); - } - sections.set(key, points); - }); - // 노견 안쪽 끝은 차도 끝과 **한 점**이어야 한다 — 노견은 제어점 보간, 차도는 - // 측점 보간이라 테이퍼 구간에서 최대 0.023m 벌어졌다(2026-08-24 실측). 차도에 맞춘다. - const roadTop = sections.get(pieceKey("carriageway", "center")); - if (roadTop && roadTop.length >= 2) { - const ends = { left: roadTop[roadTop.length - 1], right: roadTop[0] }; - (["left", "right"] as const).forEach((side) => { - const piece = sections.get(pieceKey("shoulder", side)); - if (!piece || piece.length < 2) return; - const inner = side === "left" ? piece[0] : piece[piece.length - 1]; - inner.offset_m = ends[side].offset_m; - inner.elevation_m = ends[side].elevation_m; - }); - } - // 비탈(상대 좌표)을 그 행의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견 - // 끝) 앵커에 얹고, 행 지반선과 직접 교차시켜 종결한다(2026-08-23 완전 분리). - const groundRow = mixedGround(s0.ground, s1.ground, t); - (["left", "right"] as const).forEach((side) => { - const ditchPoints = sections.get(pieceKey("ditch", side)); - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - // 비탈 앵커 = 도로부 바깥 끝. 측구가 있으면 측구 끝, 없으면 **노견 조각의 - // 바깥 끝**(구조물 구간에서 넓어진 노폭이 그대로 반영된다 — 2026-08-23). - const shoulderPoints = sections.get(pieceKey("shoulder", side)); - const shoulderOuter = shoulderPoints - ? side === "right" - ? shoulderPoints[0] - : shoulderPoints[shoulderPoints.length - 1] - : null; - const anchor = ditchPoints - ? side === "right" - ? ditchPoints[0] - : ditchPoints[ditchPoints.length - 1] - : (shoulderOuter ?? { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - // 도로부는 종단 보정을 전량 받는다 — 그 값을 얹어야 노면과 높이가 맞는다. - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t + shift, - }); - (["cut", "fill"] as const).forEach((kind) => { - const slopeKey = pieceKey(kind, side); - const slope = sections.get(slopeKey); - if (!slope || slope.length < 2) return; - // 상대 좌표 → 앵커 절대 좌표(경사 형상 보존, 위치만 도로부를 따라간다). - slope.forEach((point) => { - point.offset_m += anchor.offset_m; - point.elevation_m += anchor.elevation_m; - }); - // 기슭막이 구간 — 사면은 벽 이음선 상단에서 끝난다(지반 트림 금지). - if (fixedSlopeKeys.has(slopeKey)) return; - if (!groundRow) return; - // 절토는 지반 위로 만나는 곳까지, 성토는 아래로 만나는 곳까지 — 지반이 - // 반대편이면 이 행에 그 종류는 없다(조각 드롭 → 리본 자연 종료 = 분리). - const inner2outer = side === "right" ? [...slope].reverse() : slope; - const trimmed = trimSlopeToGround(inner2outer, kind, groundRow); - if (!trimmed) { - sections.delete(slopeKey); - return; - } - const rescaled = resample(trimmed, PIECE_COLS[kind]); - if (side === "right") rescaled.reverse(); - sections.set(slopeKey, rescaled); - }); - }); - // 클리핑 외곽 = 이 행의 실제 점유 끝(비탈 → 측구 → 노견 순) — 리본과 클리핑 - // 구멍을 행 단위로 일치시켜 서피스 상호 침범을 없앤다(2026-08-23). - const outerOf = (side: "left" | "right"): OffsetPoint => { - for (const kind of ["cut", "fill", "ditch"] as const) { - const piece = sections.get(pieceKey(kind, side)); - if (piece && piece.length >= 2) { - return side === "right" ? piece[0] : piece[piece.length - 1]; - } - } - const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; - const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; - return { - offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, - elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t, - }; - }; - rows.push({ - chainage_m: chainage, - center, - left, - sections, - outerLeft: outerOf("left"), - outerRight: outerOf("right"), - ground: groundRow, - }); - } - } - if (rows.length < 2) return null; - - const caps: CorridorCap[] = []; - /** - * 측구가 끊기는 자리의 마감벽 — 측구 단면을 **도로 가장자리 높이까지** 세로로 - * 막는다(2026-08-23 사용자 지적: 지금은 공중에서 끊겨 보인다). 위쪽 모서리를 - * 도로 쪽 끝점 높이로 두면 도로 옆면에 붙은 벽으로 마감된다. - */ - const pushDitchCap = (row: (typeof rows)[number], key: string, side: CorridorSide): void => { - const points = row.sections.get(key); - if (!points || points.length < 2) return; - const roadSide = side === "right" ? points[points.length - 1] : points[0]; - caps.push({ - points: points.map((point) => { - const [x, y] = toModel(row, point); - return [x, y, roadSide.elevation_m, point.elevation_m] as [number, number, number, number]; - }), - }); - }; - - const ribbons: CorridorRibbon[] = []; - keys.forEach((key) => { - const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; - const colCount = PIECE_COLS[kind]; - // 조각이 있는 **연속 구간**마다 리본을 따로 만든다 — 없는 구간을 폭 0으로 - // 이어 붙이면 측구가 없는 자리에도 서피스·윤곽선이 남는다(2026-08-23 수정). - let start = -1; - const flush = (endExclusive: number): void => { - if (start < 0 || endExclusive - start < 2) { - start = -1; - return; - } - const chainages: number[] = []; - const positions = new Float64Array((endExclusive - start) * colCount * 3); - // 성토 리본만 원지반 표고를 같이 싣는다 — 잘린 자리 측벽의 밑선(2026-08-27 사용자). - const groundZ = kind === "fill" ? new Float64Array((endExclusive - start) * colCount) : null; - let cursor = 0; - let groundCursor = 0; - for (let row = start; row < endExclusive; row += 1) { - chainages.push(rows[row].chainage_m); - rows[row].sections.get(key)!.forEach((point) => { - const [x, y, z] = toModel(rows[row], point); - positions[cursor] = x; - positions[cursor + 1] = y; - positions[cursor + 2] = z; - cursor += 3; - if (groundZ) { - // 지반선을 못 읽는 행은 서피스 표고를 그대로 둔다 — 높이 0짜리 벽이 된다. - groundZ[groundCursor] = rows[row].ground?.(point.offset_m) ?? z; - groundCursor += 1; - } - }); - } - ribbons.push({ kind, side, colCount, chainages, positions, ...(groundZ ? { groundZ } : {}) }); - // 측구 리본이 노선 중간에서 시작·종료하면 그 자리를 벽으로 막는다. - if (kind === "ditch") { - if (start > 0) pushDitchCap(rows[start], key, side); - if (endExclusive < rows.length) pushDitchCap(rows[endExclusive - 1], key, side); - } - start = -1; - }; - for (let row = 0; row < rows.length; row += 1) { - if (rows[row].sections.has(key)) { - if (start < 0) start = row; - } else { - flush(row); - } - } - flush(rows.length); - }); - - 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]); - }); - - // 시·종점 마구리 — 코리도가 끊기는 자리를 설계선↔지반선으로 봉인해 속이 안 보이게 한다. - [rows[0], rows[rows.length - 1]].forEach((row, index) => { - const station = index === 0 ? stations[0] : stations[stations.length - 1]; - const points = station.capSection.map(([offset, zDesign, zGround]) => { - const [x, y] = toModel(row, { offset_m: offset, elevation_m: zDesign }); - return [x, y, zDesign, zGround] as [number, number, number, number]; - }); - if (points.length >= 2) caps.push({ points }); - }); - if (legacy) return { ribbons, outline, caps, structures }; - // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 - // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. - // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. - // - // **자르기보다 먼저** 내야 한다 — 성토면을 먼저 도려내면 투영선이 z를 읽을 면이 - // 없어져 커브가 통째로 사라진다(2026-08-26). - const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons); - - // 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이 - // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). - // 잘린 자리 윤곽(`cut-merged`)은 새 데이텀에 얹어 기존 커브 **뒤에 덧붙인다**. - const cut = FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : null; - - // 성토 패치는 **투영 커브·절취를 다 낸 뒤** 붙인다 — 먼저 넣으면 투영선이 패치 - // 표면에서 z를 읽어 절취 경계가 달라지고, 절취가 패치를 다시 갉는다. - const patchRibbons = FILL_PATCH_APPLY - ? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null, planCurves) - : []; - // 패치도 같은 커브로 한 번 더 훑는다 — 단 **반대 규칙**이다(`patchClip` 표식이 붙은 - // 세월교 패치만: 절취 영역 **안쪽만** 남긴다). 지우는 면과 채우는 면이 같은 경계를 - // 공유해야 삐져나오지도 벌어지지도 않는다(2026-08-27 사용자). 병합 커브·측벽은 - // 여기서 안 나온다 — 기본 성토면 몫이다. - const clippedPatches = - FILL_CUT_APPLY && patchRibbons.length - ? maskFillByPlanCurves(patchRibbons, planCurves).ribbons - : patchRibbons; - return { - ribbons: [...(cut ? cut.ribbons : ribbons), ...clippedPatches], - outline, - caps, - structures, - planCurves: cut ? [...planCurves, ...cut.curves] : planCurves, - cutWalls: cut ? cut.walls : [], - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts deleted file mode 100644 index 9dcf752d7..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Build_Types.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Build_Types.ts - * 코리도 빌더가 내는 **결과 자료형** — 리본·절취 측벽·캡·빌드 결과. - * - * `B05_Profile_UI_Corridor_Build` 에서 떼어냈다(700줄 제한, 2026-09-02). 계산은 전혀 - * 없고 모양과 그 뜻(주석)만 있다. 빌더가 그대로 다시 내보내므로 기존 import 경로 - * (`... from "./B05_Profile_UI_Corridor_Build"`)는 전부 유효하다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorKind, CorridorSide } from "./B05_Profile_UI_Corridor_Station"; -import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -export interface CorridorRibbon { - kind: CorridorKind; - side: CorridorSide; - colCount: number; - chainages: number[]; - /** - * 구조물 **패치 리본**(2026-08-25) — 투영커브로 잘린 자리에 다시 그리는 변형 성토면. - * 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다). - */ - patch?: boolean; - /** - * 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도 - * 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 — - * 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니 - * 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다. - */ - patchClip?: boolean; - /** - * rowCount × colCount × 3 (모델 좌표). - * - * **Float64**다 — 모델 좌표는 EPSG 절대값(십만 m대)이라 Float32에 담으면 해상도가 - * 0.14m까지 떨어져, 0.2m로 세분한 점들이 양자화 계단을 타며 최대 35°짜리 지그재그가 - * 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다. - */ - positions: Float64Array; - /** - * 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다** - * (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다. - */ - cellMask?: Uint8Array; - /** - * 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형 - * (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다 - * (2026-08-26 사용자: 이원화하여 관리). - */ - trimTris?: Float64Array; - /** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */ - trimUvs?: Float32Array; - /** - * 격자점별 **원지반 표고**(rowCount × colCount) — 성토 리본에만 싣는다. - * 잘린 자리 측벽을 성토면에서 원지반까지 세우는 밑선이다(2026-08-27 사용자). - */ - groundZ?: Float64Array; -} - -/** - * 잘린 자리 **측벽 서피스**(2026-08-27 사용자) — 리본 메시에 섞지 않고 구조물처럼 - * 따로 그린다. 삼각형 수프(정점당 3, 삼각형당 9). 빗금이 없어 UV는 담지 않는다. - */ -export interface CorridorCutWall { - side: "left" | "right"; - positions: Float64Array; - /** 패치 바깥 끝 스커트인가(2026-08-27) — 뷰어가 지형 색인으로 뒤늦게 붙이므로, - * 다시 붙일 때 옛 것을 걷어내려면 표식이 있어야 한다(`_Corridor_Skirt.ts`). */ - patchSkirt?: boolean; - /** - * 정점별 UV(정점당 2) — **빗금 있는 성토 서피스**로 그릴 때만 싣는다. - * 절취 측벽은 일반 서피스(빗금 없음)이고, 패치 스커트는 성토면의 연장이라 빗금을 - * 넣는다(2026-08-27 사용자: "변형 성토면 하단 끝단부 서피스는 메시 형태"). - */ - uvs?: Float32Array; -} - -/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */ -export interface CorridorCap { - /** [x, y, z설계, z지반] × N — offset 오름차순. */ - points: Array<[number, number, number, number]>; -} - -export interface CorridorBuildResult { - ribbons: CorridorRibbon[]; - /** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */ - outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> }; - /** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */ - caps: CorridorCap[]; - /** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */ - structures: CorridorStructure[]; - /** - * 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자) — 세트 구조물 상단 5m - * 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다. - * 저장 코리도엔 없을 수 있다. - */ - planCurves?: PlanCurve[]; - /** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */ - cutWalls?: CorridorCutWall[]; - /** 측점 단면 — 새 방식 지형 변형이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */ - sections?: CrossSection[]; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts deleted file mode 100644 index 359f937c5..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve.ts +++ /dev/null @@ -1,494 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Carve.ts - * 구조물이 성토·절토면에서 도려낼 **탑뷰 투영 풋프린트**와, 잘린 자리에 다시 그리는 - * **패치 리본**(변형 성토면)을 낸다. - * - * 절단은 **순수 평면 투영**이다(2026-08-25 사용자 재확정: "말 그대로 투영이어야 함 — - * 노선을 따라 컷을 하면 다를 수밖에 없음"). 노선 파라미터(누가거리 구간)로 자르지 - * 않고, 각 코리도 행의 횡단선(실좌표 XY 직선)을 구조물·패치의 **실좌표 풋프린트 - * 사각형들과 실제로 교차**시켜 그 행에서 덮인 편거리 끝을 얻는다. 그래서 - * · 날개벽처럼 배관 구간 뒤에서 넓어지는 부재는 절단 경계도 그 벌어진 윤곽을 - * 그대로 따라간다 — 하나의 박스가 아니다(2026-08-25 사용자 ②). - * · 곡선 구간에서도 절단 경계 = 실제 탑뷰 투영이다. - * 원지반에는 쓰지 않는다(사용자: 투영 및 삭제는 원지반 제외). - * - * **탑뷰 투영 커브**(구조물 상단 5m 평면)는 여기 없다 — `_Corridor_Plan.ts`로 - * 옮겼다(2026-08-26 사용자 재작성 지시). - * - * 풋프린트 구성: - * · 구조물 솔리드 — 링과 링 사이의 평면 사각형(실좌표). - * · 변형 성토선(실루엣) 패치가 덮을 영역 — 점유 구간을 노선 프레임으로 1m씩 걸어 - * [노견 → 실루엣 바깥 끝] 사각형(실좌표). 성토부 하단(지반 접점)까지 포함이라 - * 실루엣 구간 안 행은 사실상 통째로 지워진다(2026-08-25 사용자 ④). - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { - cutInnerTo, - PIECE_COLS, - resample, - trimSlopeToGround, - type CorridorKind, - type OffsetPoint, -} from "./B05_Profile_UI_Corridor_Station"; -import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; -import { buildGroundAt, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; -import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; -import { - clipRunBelow, - extendRunTo, - NEAR_LIMIT_M, - PATCH_WING_MARGIN_M, - prependRunTo, - wingRangeAt, - wingSpanOf, - wingSpanOnRoute, -} from "./B05_Profile_UI_Corridor_Carve_Wing"; -import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union"; -import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union"; - -/** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */ -const MIN_REACH_M = 0.05; - -/** 패치 줄이 "지반에서 끝나는 줄"로 볼 허용 낙차(m) — 소유 측점 바깥 끝에서 잰다. - * 스커트 게이트(`_Corridor_Skirt.ts` PATCH_TOE_TOLERANCE_M)와 같은 값·같은 이유다. */ -const PATCH_TRIM_TOLERANCE_M = 0.5; - -/** 링 폴리곤의 편거리 양 끝 — 그 링이 평면에서 차지하는 폭. */ -export function ringEdges( - polygon: ReadonlyArray, -): [number, number] | null { - if (!polygon.length) return null; - let min = Infinity; - let max = -Infinity; - for (const [offset] of polygon) { - if (offset < min) min = offset; - if (offset > max) max = offset; - } - return max - min > 1e-6 ? [min, max] : null; -} - -/** 구조물·패치의 탑뷰 투영 풋프린트 — 행 횡단선과 교차시켜 절단 범위를 판정한다. */ -export class CarveFootprint { - private quads: FootprintQuad[] = []; - /** 스트립(연속 사각형 띠) 원본 — 진단·확장용으로 남겨 둔다. */ - private strips: Array> = []; - - get isEmpty(): boolean { - return this.quads.length === 0; - } - - /** - * 스트립 하나 추가 — `edges[i]`는 그 자리의 좌·우 가장자리 두 점이다. - * 이웃한 두 가장자리가 사각형 한 칸이 되고, 전체는 하나의 띠다. - */ - addStrip(edges: ReadonlyArray<[PXY, PXY]>): void { - if (edges.length < 2) return; - for (let i = 0; i < edges.length - 1; i += 1) { - const near = edges[i]; - const far = edges[i + 1]; - this.quads.push(unionQuadOf(near[0], near[1], far[1], far[0])); - } - this.strips.push(edges.map((edge) => [edge[0], edge[1]])); - } - - /** - * 행 횡단선(원점 c, 좌향 단위벡터 u — 편거리 o의 자리 = c + u·o)이 풋프린트와 - * 겹치는 **그 측의 가장 바깥 편거리**. 안 겹치면 null. - */ - outerAt( - cx: number, - cy: number, - ux: number, - uy: number, - side: "left" | "right", - innerOffset: number, - /** 그 행 비탈 조각의 바깥 끝 편거리 — 조각 너머에서 시작하는 겹침은 무관하다. */ - outerLimit: number, - ): number | null { - const sign = side === "left" ? 1 : -1; - let outer: number | null = null; - for (const quad of this.quads) { - // 거친 걸러내기 — 행 원점에서 너무 먼 사각형은 볼 필요 없다. - if ( - quad.minX > cx + NEAR_LIMIT_M || - quad.maxX < cx - NEAR_LIMIT_M || - quad.minY > cy + NEAR_LIMIT_M || - quad.maxY < cy - NEAR_LIMIT_M - ) { - continue; - } - // 사각형 변과 직선의 교차 — 볼록 사각형이라 교차 o들의 [min,max]가 곧 겹친 구간. - let hitMin = Infinity; - let hitMax = -Infinity; - const points = quad.points; - for (let i = 0; i < 4; i += 1) { - const a = points[i]; - const b = points[(i + 1) % 4]; - const ex = b.x - a.x; - const ey = b.y - a.y; - const denominator = ux * ey - uy * ex; - if (Math.abs(denominator) < 1e-12) continue; - const dx = a.x - cx; - const dy = a.y - cy; - const t = (ux * dy - uy * dx) / denominator; - if (t < -1e-9 || t > 1 + 1e-9) continue; - const o = Math.abs(ux) > Math.abs(uy) ? (a.x + ex * t - cx) / ux : (a.y + ey * t - cy) / uy; - if (o < hitMin) hitMin = o; - if (o > hitMax) hitMax = o; - } - if (hitMin > hitMax) continue; - // 그 측(노견 바깥)에 걸친 구간만 — 반대측 구조물은 무관하다. - const far = sign > 0 ? hitMax : hitMin; - const near = sign > 0 ? hitMin : hitMax; - if ((far - innerOffset) * sign <= MIN_REACH_M) continue; - // 조각 바깥 끝 너머에서 **시작**하는 겹침은 이 비탈과 무관하다(횡단선이 무한 - // 직선이라 멀리 굽은 노선의 다른 구조물이 얻어걸린다 — 2026-08-25 프로브에서 - // 84.3m 행이 275.7m 구조물에 걸려 -52m까지 잘려 나갔다). - if ((near - outerLimit) * sign > MIN_REACH_M) continue; - if (outer === null || (far - outer) * sign > 0) outer = far; - } - return outer; - } -} - -/** 구조물 솔리드의 링 사이 평면 사각형을 풋프린트에 담는다(`kinds` 필터 선택). */ -function addStructureQuads( - footprint: CarveFootprint, - structures: ReadonlyArray, - kinds?: ReadonlySet, -): void { - for (const structure of structures) { - if (structure.kind === "pipe") continue; - if (kinds && !kinds.has(structure.kind)) continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - const perRing = structure.polygons; - const shared = structure.polygon; - const edges = rings.map((frame, index) => { - const edge = ringEdges(perRing?.[index] ?? shared ?? []); - if (!edge) return null; - return edge.map((offset) => ({ - x: frame.cx + frame.leftX * offset, - y: frame.cy + frame.leftY * offset, - })) as [PXY, PXY]; - }); - // 링이 끊긴 자리(폴리곤 없음)에서 스트립을 나눈다 — 이어 붙이면 없는 자리까지 덮는다. - let run: Array<[PXY, PXY]> = []; - for (const edge of edges) { - if (edge) { - run.push(edge); - } else { - footprint.addStrip(run); - run = []; - } - } - footprint.addStrip(run); - } -} - -/** - * 벽·날개벽(kind="revet")만의 풋프린트 — **패치 리본을 가르는** 부재들이다. - * 구체·바닥판(kind="basin")은 패치 아래에 눕는 부재라 여기서 빼야 한다(BOX 상판 - * 위 성토선 패치가 통째로 지워진다). - */ -export function buildRevetFootprint(structures: ReadonlyArray): CarveFootprint { - const footprint = new CarveFootprint(); - addStructureQuads(footprint, structures, new Set(["revet"])); - return footprint; -} - -/** - * 행 가장자리 쌍(안쪽·바깥쪽, null = 끊김)을 **닫힌 외곽 고리**로 잇는다 — 안쪽을 - * 앞으로, 바깥쪽을 뒤로 이어 닫는다. 내부 칸막이는 안 생긴다. - */ -export function assembleStripLoops( - rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, -): Array> { - const loops: Array> = []; - let run: Array<[[number, number, number], [number, number, number]]> = []; - const flush = (): void => { - if (run.length >= 2) { - const loop: Array<[number, number, number]> = run.map((edge) => edge[0]); - for (let i = run.length - 1; i >= 0; i -= 1) loop.push(run[i][1]); - loop.push(loop[0]); - loops.push(loop); - } - run = []; - }; - for (const edge of rows) { - if (edge) run.push(edge); - else flush(); - } - flush(); - return loops; -} - -/** - * 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된 - * 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을 - * 점유 구간 전 길이에 걸쳐 노선 프레임에 얹은 별도 리본으로 만든다. 기본 성토면은 - * 건드리지 않는다(강제 변형 로직은 꺼져 있다) — 패치가 잘린 구멍을 채운다. - * - * 벽·날개벽(kind="revet")이 패치 영역을 가로지르면 행마다 그 풋프린트만큼 안쪽을 - * 잘라내고, 한 행이 통째로 덮이면 리본을 거기서 끊는다 — **분리된 서피스는 별개 - * 리본**이 된다(2026-08-25 사용자). - */ -export function buildStructurePatchRibbons( - crossSections: ReadonlyArray, - structures: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, - profileZ: ((chainageM: number) => number | null) | null, - planCurves: ReadonlyArray = [], -): CorridorRibbon[] { - const ribbons: CorridorRibbon[] = []; - const revets = buildRevetFootprint(structures); - // 행별 지반선 — 패치를 행마다 지반 교차선에서 끝내는 데 쓴다(투영 커브 - // `slope-deformed`와 같은 출처·같은 보간이라 빨간 교차선과 정확히 맞는다). - const groundOfChainage = buildGroundAt(crossSections); - for (const section of crossSections) { - const design = section.design; - /** - * BOX암거는 행 **가로 방향을 기준 측점 좌향으로 고정**한다(2026-08-27 사용자: - * "박스암거는 축 방향과 동일하게 놓여야 한다"). 행 자리는 노선 위 그대로 두고 - * 방향만 고정하는 것 — 투영 커브(`_Corridor_Plan.ts` `frameOf`)와 같은 규칙이라 - * 지우는 영역과 채우는 영역이 같은 방향으로 선다. 세월교·배수관은 노선 프레임. - */ - const boxAxis = section.box ? section.frame.left_xy : null; - /** - * 날개 몫 스윕·트림은 **세월교 전용**이다(2026-08-27 사용자: "구조물별로 구분되어야 - * 함 — 세월교, 박스암거, 기슭막이"). 날개벽은 세월교와 BOX암거 두 곳에서 쓰지만 - * 평면 스케치를 내는 로직이 서로 다르고(`wingAxis` 유무), 기슭막이(배수관 세트)는 - * 날개 사다리꼴 자체가 없다. BOX·기슭막이는 종전 경로를 그대로 탄다. - */ - const isFord = section.ford != null; - const isBox = section.box != null; - const frameOf = (chainageM: number): RouteFrame | null => { - const frame = frameAt(chainageM); - if (!frame || !boxAxis) return frame; - return { ...frame, leftX: boxAxis[0], leftY: boxAxis[1] }; - }; - const ownFrame = frameOf(section.chainage_m) ?? { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - designZ: null, - }; - for (const silhouette of structureSilhouettes(section)) { - // 노견 확장점 안쪽(수평 구간)은 노견 조각이 가져갔다 — 패치는 그 바깥부터. - // 단 그 측에 측구가 있으면 노견을 안 넓혔으므로 스트립도 하지 않는다 — 하면 - // 패치가 노견에서 떨어져 뜬다(2026-08-25 사용자 보고). - const ditch = design?.ditch; - const ditchEnabled = design?.ditch_enabled ?? (ditch != null && ditch.type !== "none"); - const ditchOnSide = ditchEnabled && design?.ditch_side === silhouette.side; - const sign = silhouette.side === "left" ? 1 : -1; - // B06이 낸 성토 구간 배열을 그대로 쓴다 — **구간 1줄 = 리본 1장**이라 다단에서도 - // 줄이 안 섞이고 역주행으로 접히지도 않는다(2026-08-27 사용자). 배열이 없으면 - // 옛 동작 한 줄: 패치 전용 점(세월교 성토부선)이 있으면 그걸 쓴다 — 벽 전면·유로를 - // 리본으로 덮으면 관 구멍·L자 통로가 막힌다(2026-08-25 사용자 ②). - // 배열이 **비어 있으면** 그 측엔 성토 구간이 없다는 뜻이라 리본도 없다 — - // 옛 한 줄로 되돌리면 축퇴 실루엣(같은 점 2개)이 폭 0 리본을 만든다. - const runs: OffsetPoint[][] = silhouette.fillRuns ?? [ - silhouette.patchPoints ?? silhouette.points, - ]; - for (let source of runs) { - // 노견에서 시작하는 줄만 확장점으로 다듬는다 — 다단 성토 구간처럼 이미 바깥에서 - // 시작하는 줄에 확장점을 앞에 붙이면 노견에 없는 판이 하나 생긴다(2026-08-27). - const widen = silhouette.widenTo; - if ( - widen && - !silhouette.patchPoints && - !ditchOnSide && - source.length > 0 && - (source[0].offset_m - widen.offset_m) * sign <= 1e-9 - ) { - source = source.filter((point) => (point.offset_m - widen.offset_m) * sign > -1e-9); - if (source.length && Math.abs(source[0].offset_m - widen.offset_m) > 1e-9) { - source = [widen, ...source]; - } - } - if (source.length < 2) continue; - // 이 줄이 소유 측점에서 지반에 닿는가 — 닿는 줄만 행별 지반 트림 대상이다. - // 벽 뒷면·구체 상단에서 끝나는 줄을 트림하면 8.1m 절벽이 되살아난다(2026-08-24). - const stationGround = groundInterpolator(section.samples); - const runOuter = source[source.length - 1]; - const runReachesGround = - stationGround !== null && - Math.abs(runOuter.elevation_m - stationGround(runOuter.offset_m)) <= - PATCH_TRIM_TOLERANCE_M; - // "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토. - let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind; - if (silhouette.kind === "auto") { - const groundAt = groundInterpolator(section.samples); - const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; - if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut"; - } - const colCount = PIECE_COLS[kind]; - const spanFrom = section.chainage_m - silhouette.span.beforeM; - const spanTo = section.chainage_m + silhouette.span.afterM; - // 날개 사다리꼴 몫만큼 더 뻗는다(2026-08-27 사용자 ②) — 지우는 영역과 맞춘다. - // 세월교는 **노선 실투영**으로 잰다 — 직선 전방 투영은 곡선 + 먼 편거리에서 - // 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다(2026-08-27 A안). BOX암거는 - // 종전 직선 투영 그대로 — 로직이 분리돼 있고 이미 채워져 있다. - const wing = isFord - ? wingSpanOnRoute(planCurves, section.chainage_m, silhouette.side, frameOf) - : wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame); - // 여유는 **구조물 점유 구간 기준** 5m다(측점 기준이 아니다 — 세월교 우측은 - // 점유 구간이 이미 측점에서 6.1m 앞까지 가 있다). 그 안에서 날개벽 끝점 - // 횡단선까지 스윕한다(2026-08-27 사용자). - const from = Math.max( - spanFrom - PATCH_WING_MARGIN_M, - Math.min(spanFrom, section.chainage_m - wing.beforeM), - ); - const to = Math.min( - spanTo + PATCH_WING_MARGIN_M, - Math.max(spanTo, section.chainage_m + wing.afterM), - ); - if (!(to - from > 1e-6)) continue; - const rowChainages: number[] = []; - for (let at = from; at < to; at += 1) rowChainages.push(at); - rowChainages.push(to); - const baseZ = profileZ?.(section.chainage_m) ?? null; - - // 행 버퍼 — 벽·날개벽에 통째로 덮인 행에서 끊어 별개 리본으로 내보낸다. - let buffer: Array<{ chainage: number; frame: RouteFrame; points: typeof source }> = []; - const flush = (): void => { - if (buffer.length >= 2) { - const positions = new Float64Array(buffer.length * colCount * 3); - buffer.forEach((row, index) => { - const shift = - baseZ != null && row.frame.designZ != null ? row.frame.designZ - baseZ : 0; - resample(row.points, colCount).forEach((point, col) => { - const i = (index * colCount + col) * 3; - positions[i] = row.frame.cx + row.frame.leftX * point.offset_m; - positions[i + 1] = row.frame.cy + row.frame.leftY * point.offset_m; - positions[i + 2] = point.elevation_m + shift; - }); - }); - ribbons.push({ - kind, - side: silhouette.side, - colCount, - chainages: buffer.map((row) => row.chainage), - positions, - patch: true, - // 절취 영역 안쪽으로 자르는 규칙은 세월교에만 걸려 있었다 — BOX암거 패치는 - // 한 번도 안 잘려 절취 영역 밖으로 넘쳤다(2026-09-02 실측: BOX 패치 리본 - // 두 벌에 셀 마스크가 없었다). BOX도 `wing-box` 커브로 절취 영역을 내므로 - // 같은 규칙을 태운다. 기슭막이(배수관 세트)는 날개 사다리꼴 자체가 없어 제외. - ...(isFord || isBox ? { patchClip: true } : {}), - }); - } - buffer = []; - }; - - for (const at of rowChainages) { - const frame = frameOf(at); - if (!frame) { - flush(); - continue; - } - // 그 행이 주황 날개 스케치와 만나는 구간(안쪽·바깥쪽 끝) — 세월교 전용. - const wingRange = isFord - ? wingRangeAt(planCurves, section.chainage_m, silhouette.side, frame) - : null; - // 여기서 행을 끊지 않는다 — 스윕 범위가 이미 **날개벽 끝점 횡단선**까지로 - // 트림돼 있다(`wingSpanOnRoute` + `PATCH_WING_MARGIN_M`). 고리와 안 만나는 - // 중간 행까지 끊으면 구조물 구간과 날개 사이가 토막 난다(2026-08-27 사용자: - // "노선방향 스윕은 좋은데 조금 부족한 구간이 있다"). - let points = source; - // 벽 트림은 **옛 한 줄** 전용이다. 날개 몫으로 늘린 행도 트림하지 않는다 — - // **날개벽 평면 스케치까지 변형 성토면에 포함**하는 게 맞다(2026-08-27 사용자): - // 벽 솔리드가 그 위에 불투명하게 앉으므로 덮여도 안 보이고, 트림을 걸면 - // 날개 사다리꼴이 통째로 비어 구멍이 남는다(실측 덮임 0~36%). - // 옛 한 줄 전용 근거: 한 줄은 구조물을 가로질러 그어져 벽이 덮은 - // 안쪽을 잘라내야 했다. B06 구간 배열은 이미 벽 **사이**에 정의돼 있어 겹치는 - // 건 아랫단 벽 뒷면 0.15m 남짓뿐이고 그것도 불투명한 벽 솔리드 안에 묻힌다. - // 반대로 트림을 걸면 바깥 끝 하나로 자르는 규칙이라 단 사이 구간이 아랫단 벽 - // 바깥까지 통째로 잘려 사라진다(2026-08-27 실측: 0.86m 구간이 벽 행에서 전멸). - if (!revets.isEmpty && !silhouette.fillRuns) { - const outerCov = revets.outerAt( - frame.cx, - frame.cy, - frame.leftX, - frame.leftY, - silhouette.side, - points[0].offset_m, - points[points.length - 1].offset_m, - ); - if (outerCov !== null) { - const cut = cutInnerTo(points, outerCov, silhouette.side); - if (!cut) { - // 이 행은 벽·날개벽이 통째로 덮는다 — 리본을 끊는다(분리 서피스). - flush(); - continue; - } - points = cut; - } - } - // 행별 지반 절단·연장(2026-08-27 사용자) — 지반에 닿는 줄은 그 행 지반선과 - // **직접 교차시켜 끝낸다**(빨간 교차선). 소유 측점 단면 한 장을 복사한 것이라 - // · 지반이 올라오면 교차점까지 **자르고**(교차선 밖은 스커트가 수직 마감), - // · 지반이 내려가면 마지막 구배 그대로 **연장**해 원본 성토 끝선까지 간다. - // 이 지반 핀이 있어야 바깥 끝이 원지반에 물리고 스커트도 선다(2026-08-27 사용자). - if (runReachesGround) { - const ground = groundOfChainage(at); - if (ground) { - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - const placed = points.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + shift, - })); - const trimmed = trimSlopeToGround(placed, kind, ground); - // null(시작부터 지반 반대편)이면 그대로 둔다 — 행을 죽이면 구멍이 된다. - if (trimmed && trimmed.length >= 2) { - // flush가 shift를 다시 얹으므로 여기서는 되돌려 저장한다. - points = trimmed.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m - shift, - })); - } - } - } - // 주황 **날개 평면 스케치(`wing-box`)** 몫은 그 위에 **덧붙인다**(2026-08-27 - // 사용자: "스윕을 노선 기준으로 더 넓히고 주황 스케치로 트림" — 지우는 게 - // 아니라 그 자리에 변형 성토면이 **있어야** 한다). 지우는 쪽이 같은 고리로 - // 성토면을 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽이 거기까지 - // 못 가면 구멍이 남는다(실측: 세월교 149.7 날개 구멍 66~98%). - // 지반 핀보다 스케치가 더 바깥일 때만 늘린다 — 안쪽이면 지반 핀을 그대로 둔다. - if (wingRange) { - // 바깥 — 스케치 경계까지 설계 성토 물매로 덧이음. - if ((wingRange.outer - points[points.length - 1].offset_m) * sign > 1e-6) { - const extended = extendRunTo(points, wingRange.outer); - if (extended) points = extended; - } - // 안쪽 — 노견(확장점)보다 안쪽으로는 안 간다. 노면을 덮으면 안 된다. - const roadLimit = - silhouette.widenTo?.offset_m ?? silhouette.points[0]?.offset_m ?? points[0].offset_m; - const innerTarget = - (wingRange.inner - roadLimit) * sign < 0 ? roadLimit : wingRange.inner; - if ((points[0].offset_m - innerTarget) * sign > 1e-6) { - const prepended = prependRunTo(points, innerTarget); - if (prepended) points = prepended; - } - } - // 바닥판 상단 Z를 넘는 점은 지운다(2026-08-27 사용자 ①). - if (silhouette.floorTopZ != null) { - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - points = clipRunBelow(points, silhouette.floorTopZ, shift); - if (points.length < 2) { - flush(); - continue; - } - } - buffer.push({ chainage: at, frame, points }); - } - flush(); - } - } - } - return ribbons; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts deleted file mode 100644 index 534cf72b1..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Carve_Wing.ts +++ /dev/null @@ -1,256 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Carve_Wing.ts - * 패치 리본(변형 성토면)의 **날개 몫** 도구 — 스윕 범위 산출과 줄 자르기·늘리기. - * `_Corridor_Carve.ts`에서 잘라 냈다(700줄 제한, 2026-08-27). - * - * 지우는 쪽은 날개 사다리꼴(`wing-box` 고리)을 영역으로 도려내는데 - * (`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽은 노선 프레임 사각격자 리본이라 - * 그대로는 그 부채꼴을 못 덮는다. 여기 도구들이 그 간극을 메운다. - * · 종방향 — `wingSpanOf`(직선 투영, BOX암거용) / `wingSpanOnRoute`(노선 실투영, 세월교용) - * · 횡방향 — `wingRangeAt`(행 횡단선 × 고리 교점의 안쪽·바깥쪽 끝) - * · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunBelow` - * - * 「주황 바깥선 트림」(버전 76~78) 도구는 **뺐다**(2026-09-02) — 그 판이 사용자 지시로 - * 원복된 뒤 다시 배선되지 않아 참조가 0이었다. 이력은 `_Corridor.ts` 버전 주석에 남는다. - * ========================================================================== */ - -import type { RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import type { OffsetPoint } from "./B05_Profile_UI_Corridor_Station"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다. - * 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */ -export const NEAR_LIMIT_M = 30; - -/** - * 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②). - * - * 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts` - * `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는 - * 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다. - * - * 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라 - * 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유). - */ -export function wingSpanOf( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frame: RouteFrame, -): { beforeM: number; afterM: number } { - // 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다. - const forwardX = -frame.leftY; - const forwardY = frame.leftX; - let before = 0; - let after = 0; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) { - for (const [x, y] of loop) { - const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY; - if (s < 0) before = Math.max(before, -s); - else after = Math.max(after, s); - } - } - } - return { beforeM: before, afterM: after }; -} - -/** 날개 고리를 노선에 실투영할 때 훑는 반경(m)과 간격(m) — 행 간격 1m보다 촘촘하다. */ -const WING_SPAN_SEARCH_M = 30; -const WING_SPAN_STEP_M = 0.25; - -/** - * 날개 몫으로 **더 스윕해 두는 여유**(m, 2026-08-27 사용자: "현재 기준보다 5m 양쪽으로 - * 늘려서 서피스를 만들고 날개벽 끝점 횡단선으로 트림"). 트림선이 이 안에 들어오므로 - * 화면 결과는 트림선까지다 — 여유는 트림선이 행 사이에 떨어져도 끊기지 않게 한다. - */ -export const PATCH_WING_MARGIN_M = 5; - -/** - * 날개 사다리꼴의 **노선 실투영** 종방향 범위(2026-08-27 사용자 A안). `wingSpanOf`는 - * 측점 프레임의 **직선** 전방 벡터에 투영해 곡률을 무시하는데, 세월교 날개는 편거리 - * -7.85m까지 나가 있어 그 근사가 3m 어긋난다(실측: 우측 고리 140.55~143.88 인데 - * 패치는 143.6부터 시작 — 고리 앞부분이 통째로 범위 밖이라 98% 구멍). - * - * 후보 누가거리를 실제 `frameAt`로 훑어 각 점의 **최근접 측점**을 찾는다. - * - * 재는 대상은 **고리 전체 점**이다. 날개벽 선 두 끝만 쓰면 좌측이 143.2~157.3 → - * 144.4~155.9로 **줄어든다**(2026-08-27 사용자: "좌측 스윕 더 가야 함") — 날개 - * 사다리꼴은 벽 끝점보다 성토 끝선 쪽으로 더 뻗기 때문이다(우측 실측: 벽 끝 142.12, - * 고리 끝 140.55). 트림은 구조물 점유 구간 ±`PATCH_WING_MARGIN_M`이 맡는다. - * BOX암거는 이 경로를 쓰지 않는다 — 로직이 분리돼 있고 이미 채워져 있다. - */ -export function wingSpanOnRoute( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frameAt: (chainageM: number) => RouteFrame | null, -): { beforeM: number; afterM: number } { - const points: Array<[number, number]> = []; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) for (const [x, y] of loop) points.push([x, y]); - } - if (!points.length) return { beforeM: 0, afterM: 0 }; - const candidates = routeCandidates(chainageM, frameAt); - if (!candidates.length) return { beforeM: 0, afterM: 0 }; - let before = 0; - let after = 0; - for (const [x, y] of points) { - const s = projectChainage(candidates, x, y) - chainageM; - if (s < 0) before = Math.max(before, -s); - else after = Math.max(after, s); - } - return { beforeM: before, afterM: after }; -} - -/** 측점 주변 후보 자리 — 고리 점을 노선에 실투영할 때 쓴다. */ -function routeCandidates( - chainageM: number, - frameAt: (chainageM: number) => RouteFrame | null, -): Array<{ at: number; cx: number; cy: number }> { - const candidates: Array<{ at: number; cx: number; cy: number }> = []; - for ( - let at = chainageM - WING_SPAN_SEARCH_M; - at <= chainageM + WING_SPAN_SEARCH_M; - at += WING_SPAN_STEP_M - ) { - const frame = frameAt(at); - if (frame) candidates.push({ at, cx: frame.cx, cy: frame.cy }); - } - return candidates; -} - -/** 점 하나의 최근접 누가거리. */ -function projectChainage( - candidates: ReadonlyArray<{ at: number; cx: number; cy: number }>, - x: number, - y: number, -): number { - let bestAt = candidates[0].at; - let bestDistance = Infinity; - for (const candidate of candidates) { - const distance = (x - candidate.cx) ** 2 + (y - candidate.cy) ** 2; - if (distance < bestDistance) { - bestDistance = distance; - bestAt = candidate.at; - } - } - return bestAt; -} - -/** - * 그 행 횡단선이 **주황 날개 평면 스케치**(`wing-box` 고리)와 만나는 **안쪽·바깥쪽 - * 끝 편거리**(2026-08-27 사용자: "스윕 범위를 날개 평면 스케치까지 지정"). 안 만나면 null. - * - * 바깥만으로는 부족하다 — 세월교 좌측 고리는 편거리 +2.64부터인데 패치는 +4.15에서 - * 시작한다(실측). 고리 몸통 1.5m가 패치 **안쪽**이라 안쪽으로도 늘려야 덮인다. - * - * 지우는 쪽이 그 고리 영역을 통째로 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) - * 채우는 쪽도 같은 고리까지 뻗어야 구멍이 안 남는다. `wingSpanOf`가 **종방향** 몫을 - * 재는 것과 짝이 되는 **가로방향** 몫이다. - */ -export function wingRangeAt( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frame: RouteFrame, -): { inner: number; outer: number } | null { - const sign = side === "left" ? 1 : -1; - let inner: number | null = null; - let outer: number | null = null; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) { - for (let i = 0; i < loop.length - 1; i += 1) { - const [ax, ay] = loop[i]; - const [bx, by] = loop[i + 1]; - const ex = bx - ax; - const ey = by - ay; - const denominator = frame.leftX * ey - frame.leftY * ex; - if (Math.abs(denominator) < 1e-12) continue; - const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator; - if (t < -1e-9 || t > 1 + 1e-9) continue; - const o = - Math.abs(frame.leftX) > Math.abs(frame.leftY) - ? (ax + ex * t - frame.cx) / frame.leftX - : (ay + ey * t - frame.cy) / frame.leftY; - // 헤어핀 건너편 고리가 무한 직선에 얻어걸리는 걸 막는다(`outerAt`과 같은 이유). - if (Math.abs(o) > NEAR_LIMIT_M) continue; - if (o * sign <= 0) continue; - if (outer === null || (o - outer) * sign > 0) outer = o; - if (inner === null || (o - inner) * sign < 0) inner = o; - } - } - } - return outer === null || inner === null ? null : { inner, outer }; -} - -/** 첫 구배 그대로 `targetOffset`까지 **안쪽으로** 늘린 줄 — 못 늘리면 null. - * 벽 뒤로 성토면을 잇는 것이라 벽 솔리드에 묻힌다(벽 전면·유로는 안 담는다 — - * 2026-08-25 사용자 ②의 "관 구멍·L자 통로를 막지 마라"는 그대로 지킨다). */ -export function prependRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { - if (run.length < 2) return null; - const head = run[0]; - const next = run[1]; - const step = next.offset_m - head.offset_m; - if (Math.abs(step) < 1e-9) return null; - const slope = (next.elevation_m - head.elevation_m) / step; - return [ - { - offset_m: targetOffset, - elevation_m: head.elevation_m + slope * (targetOffset - head.offset_m), - }, - ...run, - ]; -} - -/** - * **세월교 바닥판 상단 Z를 넘는 점을 지운다**(2026-08-27 사용자 ①). 안쪽으로 늘린 - * 성토 물매가 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀는 걸 막는다. - * - * 줄은 안쪽→바깥 순이고 성토면은 바깥으로 내려가므로 천장 초과분은 **앞머리**다. - * 지우기만 하면 경계가 격자에 걸려 계단이 되므로, 천장을 지나는 마디에 **교차점을 - * 새로 꽂는다**. 비교는 종단 shift를 얹은 **실제 표고**로 한다 — 바닥판은 측점에 - * 고정된 구조물이라 shift와 함께 움직이지 않는다. - */ -export function clipRunBelow(run: OffsetPoint[], ceilingZ: number, shift: number): OffsetPoint[] { - const above = (point: OffsetPoint): boolean => point.elevation_m + shift > ceilingZ + 1e-9; - let start = 0; - while (start < run.length && above(run[start])) start += 1; - if (start === 0) return run; - if (start >= run.length) return []; - const outside = run[start - 1]; - const inside = run[start]; - const kept = run.slice(start); - const drop = outside.elevation_m - inside.elevation_m; - if (Math.abs(drop) > 1e-9) { - const ratio = (outside.elevation_m + shift - ceilingZ) / drop; - const offset = outside.offset_m + (inside.offset_m - outside.offset_m) * ratio; - if (Math.abs(offset - inside.offset_m) > 1e-6) { - kept.unshift({ offset_m: offset, elevation_m: ceilingZ - shift }); - } - } - return kept; -} - -/** 마지막 구배(= 설계 성토 물매) 그대로 `targetOffset`까지 늘린 줄 — 못 늘리면 null. */ -export function extendRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { - if (run.length < 2) return null; - const tail = run[run.length - 1]; - const before = run[run.length - 2]; - const step = tail.offset_m - before.offset_m; - if (Math.abs(step) < 1e-9) return null; - const slope = (tail.elevation_m - before.elevation_m) / step; - return [ - ...run, - { - offset_m: targetOffset, - elevation_m: tail.elevation_m + slope * (targetOffset - tail.offset_m), - }, - ]; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts deleted file mode 100644 index 4038a8790..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Clip.ts +++ /dev/null @@ -1,468 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Clip.ts - * 서피스에서 **평면 영역**(`_Corridor_Region.ts`)이 덮는 자리를 경계 재절단으로 - * 도려낸다. 두 군데에 쓴다. - * · 지형 ← 코리도 스트립(절·성토 점유)만. 리본 절단은 Build의 파라메트릭 트림으로 옮겼다(2026-08-25) - * - * 방식(2026-08-23 사용자 확정 — 정밀): 영역 경계를 씬 수평면(x,z)에 투영한 선분으로 - * 삼각형을 판정하고, - * - 완전 내부 삼각형은 제거, - * - 경계에 걸친 삼각형은 경계 선분으로 실제 절단·재삼각분할 후 내부 조각만 버린다. - * 원본 geometry는 건드리지 않고 클리핑본을 새로 만든다 — [예상형상] 토글 OFF 시 - * 원본 완전체가 다시 보여야 하기 때문(두 벌 유지·스왑). - * - * 절단면(**밀어내기 서피스**)은 따로 만들지 않는다 — 잘린 자리에 구조물 솔리드가 - * 그대로 들어앉아 빈틈이 메워진다(2026-08-25 사용자 확정 절차 ④~⑥). 절단 경계가 - * 곧 구조물 외곽이라 각은 지지만 서피스를 뭉개지 않는다. - * - * 정점 속성(색·UV 등)은 절단 시 선형 보간해 유지한다 — 리본은 UV로 빗금을 반복하므로 - * UV가 끊기면 절단 자리에서 해칭이 튄다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { P2, PlanRegion, RegionSegment } from "./B05_Profile_UI_Corridor_Region"; -import { - CorridorStrip, - PlanLoopRegion, - SubtractedRegion, - pointInTri2, - segmentsIntersect, -} from "./B05_Profile_UI_Corridor_Region"; - -export { CorridorStrip }; - -/** 보간해서 이어 갈 정점 속성 한 종류. */ -interface AttrSlot { - name: string; - itemSize: number; - source: THREE.BufferAttribute; - values: ArrayLike; - extra: number[]; -} - -/** 절단 대상 삼각형 — 씬 좌표 정점 3개와 정점 속성(슬롯 순서). */ -interface Tri { - px: Float64Array; // [x0,y0,z0, x1,y1,z1, x2,y2,z2] - attrs: Float64Array[]; // 슬롯마다 [v0…, v1…, v2…] -} - -/** 절단 대상에서 빼는 속성 — 위치는 따로 다루고, 법선은 원본 것만 물려받는다. */ -const SKIPPED_ATTRIBUTES = new Set(["position", "normal"]); - -/** - * 여러 영역을 하나처럼 본다 — 어느 하나에라도 들면 "안"이다. - * `sceneY`를 주면 그 표고를 덮는 영역만 센다(구조물 밑을 지나는 면은 안 지운다). - */ -function insideAny(regions: ReadonlyArray, p: P2, sceneY?: number): boolean { - return regions.some((region) => region.contains(p, sceneY)); -} - -function segmentsNearAny( - regions: ReadonlyArray, - minX: number, - maxX: number, - minZ: number, - maxZ: number, -): RegionSegment[] { - if (regions.length === 1) return regions[0].segmentsNear(minX, maxX, minZ, maxZ); - const result: RegionSegment[] = []; - for (const region of regions) result.push(...region.segmentsNear(minX, maxX, minZ, maxZ)); - return result; -} - -/** 삼각형을 선분의 무한직선으로 절단해 소삼각형 목록으로 — 실교차 시에만 호출. */ -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 => dir.x * (z - p.z) - dir.z * (x - p.x); - 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]; - - /** 정점 하나의 값 묶음 — 위치 3개 + 슬롯별 값. */ - interface Vertex { - p: number[]; - a: number[][]; - } - const vertexOf = (i: number): Vertex => ({ - p: [tri.px[i * 3], tri.px[i * 3 + 1], tri.px[i * 3 + 2]], - a: tri.attrs.map((values, slot) => { - const size = values.length / 3; - void slot; - return Array.from({ length: size }, (_unused, c) => values[i * size + c]); - }), - }); - const lerpVertex = (i: number, j: number): Vertex => { - const t = d[i] / (d[i] - d[j]); - return { - p: [0, 1, 2].map( - (axis) => tri.px[i * 3 + axis] + (tri.px[j * 3 + axis] - tri.px[i * 3 + axis]) * t, - ), - a: tri.attrs.map((values) => { - const size = values.length / 3; - return Array.from( - { length: size }, - (_unused, c) => values[i * size + c] + (values[j * size + c] - values[i * size + c]) * t, - ); - }), - }; - }; - const makeTri = (a: Vertex, b: Vertex, c: Vertex): Tri => ({ - px: Float64Array.from([...a.p, ...b.p, ...c.p]), - attrs: a.a.map((_unused, slot) => - Float64Array.from([...a.a[slot], ...b.a[slot], ...c.a[slot]]), - ), - }); - - // 한쪽 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, - }; -} - -/** 삼각형 무게중심의 씬 Y — 구조물 상단과 견주어 덮이는지 판정한다. */ -function triCentroidY(tri: Tri): number { - return (tri.px[1] + tri.px[4] + tri.px[7]) / 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) - ); -} - -/** 보간해 이어 갈 속성 슬롯 목록 — 위치·법선을 뺀 나머지 전부. */ -function attributeSlots(geometry: THREE.BufferGeometry): AttrSlot[] { - const slots: AttrSlot[] = []; - for (const name of Object.keys(geometry.attributes)) { - if (SKIPPED_ATTRIBUTES.has(name)) continue; - const attribute = geometry.getAttribute(name) as THREE.BufferAttribute | undefined; - if (!attribute) continue; - // 배열은 count*itemSize보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히 - // 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다 - // (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인). - const values = ( - attribute.array as unknown as { subarray(a: number, b: number): ArrayLike } - ).subarray(0, attribute.count * attribute.itemSize); - slots.push({ name, itemSize: attribute.itemSize, source: attribute, values, extra: [] }); - } - return slots; -} - -/** - * BufferGeometry 하나를 영역들로 재절단한다. - * 반환: 영역 밖 조각만 남긴 geometry (변화 없으면 null). - */ -export function clipGeometry( - geometry: THREE.BufferGeometry, - regions: ReadonlyArray, -): THREE.BufferGeometry | null { - if (!regions.length) return null; - const position = geometry.getAttribute("position") as THREE.BufferAttribute | undefined; - if (!position) return null; - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : position.count / 3; - - // 성능(2026-08-23 프리즈 개선): 원본 정점 버퍼는 그대로 두고 **인덱스만** 새로 만든다. - // 영역은 서피스 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를 - // 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만 - // 새 정점으로 뒤에 덧붙인다. - const px = (position.array as Float32Array).subarray(0, position.count * 3); - const slots = attributeSlots(geometry); - const keptIndices: number[] = []; - const extraPositions: number[] = []; - const baseCount = position.count; - let changed = false; - - const regionMinX = Math.min(...regions.map((region) => region.minX)); - const regionMaxX = Math.max(...regions.map((region) => region.maxX)); - const regionMinZ = Math.min(...regions.map((region) => region.minZ)); - const regionMaxZ = Math.max(...regions.map((region) => region.maxZ)); - - const readTri = (ia: number, ib: number, ic: number): Tri => ({ - px: Float64Array.from([ - px[ia * 3], - px[ia * 3 + 1], - px[ia * 3 + 2], - px[ib * 3], - px[ib * 3 + 1], - px[ib * 3 + 2], - px[ic * 3], - px[ic * 3 + 1], - px[ic * 3 + 2], - ]), - attrs: slots.map((slot) => - Float64Array.from( - [ia, ib, ic].flatMap((v) => - Array.from({ length: slot.itemSize }, (_unused, c) => slot.values[v * slot.itemSize + c]), - ), - ), - ), - }); - /** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */ - const emitFragment = (tri: Tri): void => { - for (let v = 0; v < 3; v += 1) { - keptIndices.push(baseCount + extraPositions.length / 3); - extraPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]); - slots.forEach((slot, s) => { - for (let c = 0; c < slot.itemSize; c += 1) { - slot.extra.push(tri.attrs[s][v * slot.itemSize + c]); - } - }); - } - }; - - 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 ax = px[ia * 3]; - const az = px[ia * 3 + 2]; - const bx = px[ib * 3]; - const bz = px[ib * 3 + 2]; - const ccx = px[ic * 3]; - const ccz = px[ic * 3 + 2]; - const minX = Math.min(ax, bx, ccx); - const maxX = Math.max(ax, bx, ccx); - const minZ = Math.min(az, bz, ccz); - const maxZ = Math.max(az, bz, ccz); - // 영역 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만). - if (maxX < regionMinX || minX > regionMaxX || maxZ < regionMinZ || minZ > regionMaxZ) { - keptIndices.push(ia, ib, ic); - continue; - } - const near = segmentsNearAny(regions, minX, maxX, minZ, maxZ); - if (near.length === 0) { - // 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는 - // 삼각형은 안/밖 어느 한쪽에 통째로 있다). - const cy = (px[ia * 3 + 1] + px[ib * 3 + 1] + px[ic * 3 + 1]) / 3; - if (insideAny(regions, { x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 }, cy)) { - changed = true; - continue; - } - keptIndices.push(ia, ib, ic); - continue; - } - // 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정. - const tri = readTri(ia, ib, ic); - 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) => !insideAny(regions, triCentroid(fragment), triCentroidY(fragment)), - ); - if (kept.length === fragments.length) { - keptIndices.push(ia, ib, ic); // 절단은 됐지만 전부 밖 — 원본 인덱스 유지. - } else { - changed = true; - kept.forEach(emitFragment); - } - } - - if (!changed) return null; - const clipped = new THREE.BufferGeometry(); - const positions = new Float32Array(baseCount * 3 + extraPositions.length); - positions.set(px, 0); - positions.set(extraPositions, baseCount * 3); - clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - for (const slot of slots) { - // 정점색은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 종류 그대로** 담고 - // normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면 - // 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증). - const source = slot.source.array as unknown as { - constructor: new (length: number) => { set(a: ArrayLike, o?: number): void }; - }; - const merged = new source.constructor(baseCount * slot.itemSize + slot.extra.length); - merged.set(slot.values, 0); - // 정수 배열이면 보간값 소수부가 잘린다 — 반올림해 담는다. - merged.set( - slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra, - baseCount * slot.itemSize, - ); - clipped.setAttribute( - slot.name, - new THREE.BufferAttribute( - merged as unknown as THREE.TypedArray, - slot.itemSize, - slot.source.normalized, - ), - ); - } - clipped.setIndex(keptIndices); - // 법선은 원본이 가진 것만 물려받는다. 원본에 법선이 없으면(GLB가 안 실어 보내는 - // 경우) **우리도 만들지 않는다** — 직접 계산하면 감기 방향이 원본과 달라져 지형이 - // 새까맣게 보였다(2026-08-23 화면 검증). 없을 때의 음영은 THREE가 알아서 만든다. - const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - if (normal && normal.count >= baseCount) { - const normals = new Float32Array(baseCount * 3 + extraPositions.length); - normals.set((normal.array as Float32Array).subarray(0, baseCount * 3), 0); - for (let i = 0; i < extraPositions.length; i += 3) { - normals[baseCount * 3 + i] = 0; - normals[baseCount * 3 + i + 1] = 1; - normals[baseCount * 3 + i + 2] = 0; - } - clipped.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); - } - return clipped; -} - -/** 포인트클라우드(meshfree) — 영역 안 점만 걷어낸다. */ -function clipPoints( - geometry: THREE.BufferGeometry, - regions: ReadonlyArray, -): THREE.BufferGeometry { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const keep: number[] = []; - for (let i = 0; i < position.count; i += 1) { - if (!insideAny(regions, { 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 clipped = new THREE.BufferGeometry(); - clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - if (color) { - const size = color.itemSize; - const colors = new Float32Array(keep.length * size); - keep.forEach((src, dst) => { - for (let c = 0; c < size; c += 1) { - colors[dst * size + c] = (color.array as ArrayLike)[src * size + c]; - } - }); - clipped.setAttribute("color", new THREE.BufferAttribute(colors, size, color.normalized)); - } - return clipped; -} - -/** - * 구조물 안착 자리에서 **원지반을 되살린다** — 그 자리 지형을 도려내지 않고 **원본 - * 지형 메시 그대로** 남긴다(2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까", - * "회색 서피스는 있는데 원지반과 같은 메시가 아니잖아. 너가 그냥 만든 거잖아"). - * - * 되살릴 영역 = **`cut-merged`** — 성토면에서 **실제로 도려낸 셀**을 되짚어 낸 윤곽이다 - * (`_Corridor_Cut.ts` `traceMaskLoops`). 지우는 쪽이 남긴 자국을 그대로 쓰므로, 구조물 - * 풋프린트로 영역을 다시 조립할 필요가 없다. - * - * 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 **다시 만들었다**. - * 지우는 쪽(성토 셀 마스크)과 되살리는 쪽(구조물 고리)이 서로 다른 커브를 써 경계가 - * 어긋났고, 날개벽이 패널 두께 슬리버로 남아 그 자리만 성토면도 지형도 없는 검은 줄이 - * 됐다. BOX암거에서는 변형 성토선이 원본과 collinear가 되어 영역이 통째로 공집합이 됐다. - * - * `cut-merged`가 없으면(절취를 안 했으면) 되살릴 것도 없다 — 스트립 그대로. - */ -function withStructureHoles( - strip: CorridorStrip, - build: CorridorBuildResult, - bounds: ModelBounds, -): PlanRegion { - const loops: Array> = []; - for (const curve of build.planCurves ?? []) { - if (curve.source !== "cut-merged") continue; - for (const loop of curve.loops) { - if (loop.length >= 4) loops.push(loop.map(([x, y]) => [x, y] as [number, number])); - } - } - if (!loops.length) return strip; - const expose = new PlanLoopRegion(loops, bounds); - if (expose.isEmpty) return strip; - // 검증용 요약(화면 밖 수치 확인) — `__corridorClip`과 같은 용도. 고리 수만으로는 - // 되살릴 영역이 실제로 남았는지 알 수 없어(BOX에서 공집합인데도 정상값이 찍혔다) - // 영역을 격자로 찍은 실적을 함께 담는다. 코리도 빌드당 한 번이라 비용은 무시할 만하다. - const SAMPLES = 80; - let inside = 0; - for (let i = 0; i < SAMPLES; i += 1) { - const x = expose.minX + ((expose.maxX - expose.minX) * (i + 0.5)) / SAMPLES; - for (let j = 0; j < SAMPLES; j += 1) { - const z = expose.minZ + ((expose.maxZ - expose.minZ) * (j + 0.5)) / SAMPLES; - if (expose.contains({ x, z })) inside += 1; - } - } - (window as unknown as { __corridorHoles?: unknown }).__corridorHoles = { - /** 되살릴 영역 고리 수 = 실제로 도려낸 자리 윤곽(`cut-merged`) 개수. */ - exposeLoops: loops.length, - exposeCells: inside, - exposeRatio: Number((inside / (SAMPLES * SAMPLES)).toFixed(4)), - }; - return new SubtractedRegion(strip, [expose]); -} - -/** - * 코리도 스트립으로 도려낸 지형을 만든다 — 원본은 불변. - * - * **구조물 투영커브는 여기 쓰지 않는다**(2026-08-25 사용자: 투영 및 삭제는 원지반 제외). - * 원지반은 코리도 점유분만 걷어내고, 구조물이 덮는 자리는 성토·절토 리본에서만 도려낸다. - * - * 분할본(TerrainBandSplit)을 받으면 **노선 주변(near)만** 재트림하고 원거리(far)는 - * 처음 만든 그대로 다시 쓴다(2026-08-23 사용자 제안). 편집마다 지형 전체를 - * 훑던 비용이 밴드 안쪽으로 줄어든다. - */ -export function clipTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, -): THREE.Object3D { - const regions: PlanRegion[] = [ - withStructureHoles(new CorridorStrip(build, bounds), build, bounds), - ]; - const root = new THREE.Group(); - root.name = "terrain-clipped"; - // geometry·material 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다. - // 이 그룹이 새로 만든 것(userData.owned)만 disposeClippedTerrain이 정리한다. - split.near.forEach((part) => { - if (part.points) { - const points = new THREE.Points( - clipPoints(part.geometry, regions), - part.material as THREE.Material, - ); - points.userData.owned = true; - root.add(points); - return; - } - const clipped = clipGeometry(part.geometry, regions); - const mesh = new THREE.Mesh(clipped ?? part.geometry, part.material); - mesh.userData.owned = clipped !== null; - root.add(mesh); - }); - // 원거리 조각은 코리도와 무관하다 — 그대로 얹는다(재트림·재생성 없음). - split.far.forEach((part) => { - const mesh = new THREE.Mesh(part.geometry, part.material); - mesh.userData.owned = false; - root.add(mesh); - }); - return root; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts deleted file mode 100644 index 42b90c524..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Cut.ts +++ /dev/null @@ -1,620 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Cut.ts - * 탑뷰 투영 커브(빨간 선) **안쪽 성토면 지우기** — 2026-08-26 사용자. - * - * 방식: 정점은 **하나도 건드리지 않는다**. 격자 셀(사각형) 중 **중심이 커브 안에 - * 드는 셀만 마스크**해 그리기 단계에서 삼각형을 빼면 그 자리가 비워진다. - * - * 왜 이렇게(2026-08-26 사용자 지적): 리본은 `행 × 열` 직사각 격자라 한 행 안에 - * 들쭉날쭉한 구멍을 담지 못한다. 그래서 종전처럼 행을 잘라 내는 방식은 결국 - * **행 경계 = 노선 기준**으로 떨어져, BOX암거처럼 커브가 노선과 비스듬한 자리에서 - * 파란선 바깥까지 잘려 나갔다(실측: 좌측 커브 바깥 변은 200.37~201.91인데 구멍은 - * 200.18~202.11). 셀 마스크는 그 한계가 없다 — 리샘플·조각 이어붙이기도 필요 없다. - * - * **선택 영역은 하나다**(2026-08-26 사용자): 지울 자리는 결국 상단 평면 스케치가 - * 덮는 XY 영역이고, 투영선은 그 XY를 그대로 복사한 것(Z만 서피스에서 읽음)이다. - * 그래서 구조물 종류를 가리지 않고 **한 가지 방법**으로 자른다 — 종전에 노선 흐름 - * 커브와 축 고정 커브(BOX)를 나눠 다루려 했으나, 영역이 같은데 방법만 갈라 두면 - * 한쪽은 계단, 다른 쪽은 지그재그가 됐다. - * - * 클리핑은 **셀 하나를 직선 하나로** 자른다. 셀은 폭 6cm·길이 0.2m라 그 안에서 커브는 - * 사실상 직선이고, 볼록 사각형을 직선으로 자르면 결과도 볼록이라 부채꼴 삼각분할이 - * 항상 옳다. 다각형 대 다각형 클리핑을 시도했다가 되살릴 면적의 40%를 잃고 톱니가 - * 났던 전례가 있다(2026-08-26 실측) — 교점 두 개로 직선을 세우는 지금 방식이 그 함정을 - * 피한다. 자를 직선을 못 세운 셀은 **안쪽 면적 비율**로 가른다(2026-08-27 사용자): - * 절반 이상 잠겼으면 지우고 아니면 남긴다. 종전처럼 통째로 남기면 귀퉁이가 경계에 딱 - * 붙어 안팎 판정을 못 받은 셀들이 경계에 미세 메쉬로 줄줄이 남았다. - * - * 잘린 자리는 비워 둔다 — 구조물 솔리드가 들어앉아 메운다(2026-08-25 사용자 확정). - * 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만). - * ========================================================================== */ - -import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; -import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; - -/** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */ -interface CurveArea { - points: Array<[number, number]>; - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -/** 광선 투사 내부 판정(XY). */ -function inside(area: CurveArea, x: number, y: number): boolean { - if (x < area.minX || x > area.maxX || y < area.minY || y > area.maxY) return false; - const points = area.points; - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const [xi, yi] = points[i]; - const [xj, yj] = points[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** 셀 네 귀퉁이 — 링 순서(row,col) → (row,col+1) → (row+1,col+1) → (row+1,col). */ -interface Corner { - p: [number, number, number]; - /** 셀 안 상대좌표 — a: 열 방향(0~1), b: 행 방향(0~1). UV 보간에 쓴다. */ - a: number; - b: number; -} - -function areaOf(loop: ReadonlyArray<[number, number, number]>): CurveArea { - const points = loop.map((point) => [point[0], point[1]] as [number, number]); - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const [x, y] of points) { - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minY = Math.min(minY, y); - maxY = Math.max(maxY, y); - } - return { points, minX, maxX, minY, maxY }; -} - -/** 셀 둘레와 커브 경계의 교점(둘레 진행 순서). */ -function ringCrossings(areas: ReadonlyArray, ring: ReadonlyArray): Corner[] { - const hits: Corner[] = []; - for (let i = 0; i < ring.length; i += 1) { - const from = ring[i]; - const to = ring[(i + 1) % ring.length]; - const rx = to.p[0] - from.p[0]; - const ry = to.p[1] - from.p[1]; - const ts: number[] = []; - for (const area of areas) { - const points = area.points; - for (let k = 0, j = points.length - 1; k < points.length; j = k, k += 1) { - const sx = points[k][0] - points[j][0]; - const sy = points[k][1] - points[j][1]; - const denominator = rx * sy - ry * sx; - if (Math.abs(denominator) < 1e-12) continue; - const dx = points[j][0] - from.p[0]; - const dy = points[j][1] - from.p[1]; - const t = (dx * sy - dy * sx) / denominator; - const u = (dx * ry - dy * rx) / denominator; - if (t >= -1e-9 && t <= 1 + 1e-9 && u >= 0 && u <= 1) ts.push(Math.min(1, Math.max(0, t))); - } - } - ts.sort((p, q) => p - q); - for (const t of ts) { - const last = hits[hits.length - 1]; - const point: Corner = { - p: [from.p[0] + rx * t, from.p[1] + ry * t, from.p[2] + (to.p[2] - from.p[2]) * t], - a: from.a + (to.a - from.a) * t, - b: from.b + (to.b - from.b) * t, - }; - // 같은 자리 중복(모서리 통과) 제거. - if (last && Math.hypot(last.p[0] - point.p[0], last.p[1] - point.p[1]) < 1e-6) continue; - hits.push(point); - } - } - return hits; -} - -/** 셀 클리핑 결과 — 남길 바깥 조각과, 실제로 잘린 **교점 직선**(측벽이 설 자리). */ -interface CellClip { - kept: Corner[]; - chord: [Corner, Corner]; -} - -/** - * 셀을 교점 두 개가 만드는 **직선 하나로** 자르고, 커브 **바깥** 쪽 조각을 돌려준다. - * 교점이 2개가 아니면 null — 부르는 쪽이 셀을 통째로 남긴다. - */ -function clipCellByChord( - areas: ReadonlyArray, - ring: ReadonlyArray, - /** 남길 쪽 — 기본은 커브 **밖**(기본 성토면). 패치는 **안**을 남긴다. */ - keep: "outside" | "inside" = "outside", -): CellClip | null { - const hits = ringCrossings(areas, ring); - // 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데, - // 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라 - // 그 안에서 커브는 사실상 직선). 2개일 때만 자르면 3분의 2가 통째로 남아 경계가 - // 다시 지그재그가 됐다(2026-08-26 실측: 걸친 셀 1,230개 중 816개가 미절단). - if (hits.length < 2) return null; - const h0 = hits[0]; - const h1 = hits[hits.length - 1]; - const lx = h1.p[0] - h0.p[0]; - const ly = h1.p[1] - h0.p[1]; - if (Math.hypot(lx, ly) < 1e-9) return null; - /** 직선 왼쪽/오른쪽 부호. */ - const sideOf = (x: number, y: number): number => lx * (y - h0.p[1]) - ly * (x - h0.p[0]); - // 바깥 쪽 부호 — 커브 밖인 귀퉁이로 정한다. - let keepSign = 0; - for (const corner of ring) { - if (areas.some((area) => inside(area, corner.p[0], corner.p[1]))) continue; - const value = sideOf(corner.p[0], corner.p[1]); - if (Math.abs(value) > 1e-9) { - keepSign = Math.sign(value); - break; - } - } - if (!keepSign) return null; - if (keep === "inside") keepSign = -keepSign; - // 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다. - const kept: Corner[] = []; - for (let i = 0; i < ring.length; i += 1) { - const from = ring[i]; - const to = ring[(i + 1) % ring.length]; - const sFrom = sideOf(from.p[0], from.p[1]) * keepSign; - const sTo = sideOf(to.p[0], to.p[1]) * keepSign; - if (sFrom >= -1e-9) kept.push(from); - if ((sFrom > 0 && sTo < 0) || (sFrom < 0 && sTo > 0)) { - const t = sFrom / (sFrom - sTo); - kept.push({ - p: [ - from.p[0] + (to.p[0] - from.p[0]) * t, - from.p[1] + (to.p[1] - from.p[1]) * t, - from.p[2] + (to.p[2] - from.p[2]) * t, - ], - a: from.a + (to.a - from.a) * t, - b: from.b + (to.b - from.b) * t, - }); - } - } - return kept.length >= 3 ? { kept, chord: [h0, h1] } : null; -} - -/** - * 셀 안쪽 면적 비율 — 4×4 격자 표본으로 어림한다(2026-08-27). - * - * 클리핑이 실패한 셀을 가르는 데만 쓴다. 실패 셀은 거의 전부 **귀퉁이가 커브 경계에 - * 딱 붙어** `inCount === 4` 판정을 못 받았을 뿐 속은 통째로 안쪽이다(실측: 실패 - * 627개 중 622개가 비율 1.00). - */ -function coverage(areas: ReadonlyArray, ring: ReadonlyArray): number { - const STEPS = 4; - let hits = 0; - for (let i = 0; i < STEPS; i += 1) { - const a = (i + 0.5) / STEPS; - const topX = ring[0].p[0] + (ring[1].p[0] - ring[0].p[0]) * a; - const topY = ring[0].p[1] + (ring[1].p[1] - ring[0].p[1]) * a; - const bottomX = ring[3].p[0] + (ring[2].p[0] - ring[3].p[0]) * a; - const bottomY = ring[3].p[1] + (ring[2].p[1] - ring[3].p[1]) * a; - for (let j = 0; j < STEPS; j += 1) { - const b = (j + 0.5) / STEPS; - const x = topX + (bottomX - topX) * b; - const y = topY + (bottomY - topY) * b; - if (areas.some((area) => inside(area, x, y))) hits += 1; - } - } - return hits / (STEPS * STEPS); -} - -/** 절취 영역이 되는 커브 — 비탈 투영선과 **날개벽 사다리꼴**의 합집합(2026-08-27 사용자). */ -const CUT_SOURCES: ReadonlyArray = ["slope-projected", "wing-box"]; - -/** 병합 커브를 올릴 **새 데이텀** — 그 세트 평면 대비 이만큼 위(2026-08-27 사용자). */ -const MERGED_LIFT_M = 3; - -/** 세트 평면 — 병합 커브를 어느 높이·어느 유입유출로 낼지 정한다. */ -interface SetPlane { - chainage: number; - planZ: number; - role: PlanCurve["role"]; -} - -/** - * 마스크된 셀 덩어리의 **격자 윤곽**을 고리로 추적한다 — 실제로 잘리는 자리다. - * 셀 하나가 6cm × 0.2m라 계단이 그 크기를 넘지 않는다. - */ -function traceMaskLoops( - rows: number, - cols: number, - mask: Uint8Array, -): Array> { - const on = (row: number, col: number): boolean => - row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 && mask[row * (cols - 1) + col] === 1; - const key = (row: number, col: number): number => row * cols + col; - const edges = new Map>(); - const push = (fromRow: number, fromCol: number, toRow: number, toCol: number): void => { - const list = edges.get(key(fromRow, fromCol)) ?? []; - list.push([toRow, toCol]); - edges.set(key(fromRow, fromCol), list); - }; - // 마스크된 셀에서 **이웃이 안 마스크된 쪽 모서리**만 담는다 — 링 방향을 맞춰 담아야 - // 이어 붙일 때 한 방향으로 돈다. - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - if (!on(row, col)) continue; - if (!on(row - 1, col)) push(row, col, row, col + 1); - if (!on(row, col + 1)) push(row, col + 1, row + 1, col + 1); - if (!on(row + 1, col)) push(row + 1, col + 1, row + 1, col); - if (!on(row, col - 1)) push(row + 1, col, row, col); - } - } - /** - * 갈림목에서 **가장 오른쪽으로 도는 변**을 고른다 — 변은 마스크된 셀을 왼쪽에 두고 - * 담기므로(반시계), 오른손 규칙을 지켜야 두 덩어리가 맞닿은 자리에서 서로 다른 - * 고리로 갈린다. 아무 변이나 집으면(`pop()`) walk가 남의 고리로 새 나가 막다른 - * 길에 빠지고, 그 **열린 사슬이 고리로 담긴다** — 그 위에 point-in-polygon을 걸면 - * 판정이 무너진다(2026-08-27 실측: 도려낸 셀의 18%가 되살릴 영역에서 빠졌다). - */ - const pickTurn = ( - from: [number, number], - at: [number, number], - options: ReadonlyArray<[number, number]>, - ): number => { - const inRow = at[0] - from[0]; - const inCol = at[1] - from[1]; - let best = 0; - let bestScore = -1; - options.forEach((to, index) => { - const outRow = to[0] - at[0]; - const outCol = to[1] - at[1]; - // 외적(왼쪽 양수) → 오른쪽 회전일수록 작다. 내적으로 직진/후진을 가른다. - const cross = inCol * outRow - inRow * outCol; - const dot = inCol * outCol + inRow * outRow; - // 우회전(3) > 직진(2) > 좌회전(1) > 되돌아가기(0). - const score = cross < 0 ? 3 : dot > 0 ? 2 : cross > 0 ? 1 : 0; - if (score > bestScore) { - bestScore = score; - best = index; - } - }); - return best; - }; - const loops: Array> = []; - for (const start of [...edges.keys()]) { - const list = edges.get(start); - while (list?.length) { - const head: [number, number] = [Math.floor(start / cols), start % cols]; - const loop: Array<[number, number]> = [head]; - let previous = head; - let current = list.pop() as [number, number]; - let closed = false; - for (let guard = 0; guard < 100000; guard += 1) { - loop.push(current); - if (current[0] === head[0] && current[1] === head[1]) { - closed = true; - break; - } - const next = edges.get(key(current[0], current[1])); - if (!next?.length) break; - const pick = next.length === 1 ? 0 : pickTurn(previous, current, next); - previous = current; - current = next.splice(pick, 1)[0]; - } - // **닫힌 고리만** 쓴다 — 열린 사슬은 영역 판정에 쓸 수 없다. 위 규칙대로 돌면 - // 정상 마스크에서는 언제나 닫힌다(안 닫히면 그 덩어리는 다음 시작점에서 다시 잡힌다). - if (closed && loop.length >= 4) loops.push(loop); - } - } - return loops; -} - -/** - * 실좌표에서 **정말 일직선인** 점만 버린다 — 직선 구간이 점 수천 개가 되지 않게. - * - * 격자 인덱스로 재면 안 된다. 코리도 격자는 노선을 따라 **휘어 있어** 인덱스로 직선인 - * 구간이 실좌표에서는 곡선이다. 인덱스 기준으로 지우면 폴리곤이 실제 마스크 **안쪽으로 - * 코너를 잘라먹고**, 그 밖으로 삐져나온 셀은 되살릴 영역에서 빠져 구멍이 된다 - * (2026-08-27 실측: 도려낸 셀의 18%, 전부 편거리가 큰 바깥 끝 열 40~46. - * 사용자: "유출구에 일부 성토부에 터짐 있음"). - */ -const COLLINEAR_TOLERANCE_M = 0.001; - -function dropCollinear( - loop: ReadonlyArray<[number, number, number]>, -): Array<[number, number, number]> { - // 추적 고리는 머리 점이 꼬리에 한 번 더 실려 온다(닫힘 표시). **순환으로 다듬기 전에 - // 뗀다** — 그대로 두면 머리 점의 이웃이 자기 자신이 되어 `away = 0`으로 지워지고, 그 - // 모서리 양옆의 일직선 점들까지 함께 빠져 **시작 모서리가 대각선으로 잘려 나갔다** - // (2026-09-02 실측: 고리마다 첫 행 쐐기 63셀이 되살릴 영역에서 빠져 구멍이 됐다). - const closed = - loop.length > 1 && - loop[0][0] === loop[loop.length - 1][0] && - loop[0][1] === loop[loop.length - 1][1]; - const ring = closed ? loop.slice(0, -1) : loop; - if (ring.length < 3) return [...loop]; - const kept: Array<[number, number, number]> = []; - for (let i = 0; i < ring.length; i += 1) { - // 앞점은 **마지막으로 남긴 점**이다 — 바로 앞 원점으로 재면 완만한 곡선에서 점마다 - // 1mm 미만이라 전부 빠지고, 남은 긴 현이 곡선 안쪽으로 2cm 파고든다(2026-09-02 실측: - // 직선부 노견 열의 셀 중심이 현 위에 놓였다). `_Region.ts` `simplifyLoop`과 같은 규칙. - const prev = kept[kept.length - 1] ?? ring[ring.length - 1]; - const at = ring[i]; - const next = ring[(i + 1) % ring.length]; - const dx = next[0] - prev[0]; - const dy = next[1] - prev[1]; - const span = Math.hypot(dx, dy); - if (span > 1e-9) { - // prev→next 선분에서 잰 수직거리 — 이만큼도 안 벗어나면 없어도 같은 선이다. - const away = Math.abs(dx * (at[1] - prev[1]) - dy * (at[0] - prev[0])) / span; - if (away < COLLINEAR_TOLERANCE_M) continue; - } - kept.push(at); - } - // 닫힘 표시는 그대로 돌려준다 — 스케치가 `Line`으로 그려져 꼬리 점이 있어야 닫힌다. - if (closed && kept.length) kept.push(kept[0]); - return kept; -} - -/** 측벽을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다. - * 지형은 코리도 자리에서 이미 잘려 있어 더 내려도 가려진다(2026-08-27 사용자). */ -const SKIRT_MARGIN_M = 1; - -/** - * 잘린 자리 **측벽**(2026-08-27 사용자) — **평면 스케치에서 바로** 세운다. - * - * 벽이 설 자리는 Plan이 `cut-wall` 커브로 넘겨준다(스트립 양 끝 횡단선 + 날개 - * 사다리꼴의 [끝선→날개→끝선]). 노견선·성토 끝선 변은 애초에 빠져 있다. - * 셀을 훑지 않으므로 커브 마디 하나당 판 하나다 — 종전에는 셀마다 한 장씩 나와 - * 세월교 한 세트에 96~111조각(길이 5~20cm)이 깔렸다(2026-08-27 실측). - * - * 표고만 성토 리본에서 읽는다 — 행 하나는 XY상 직선이라 점을 그 선분에 투영해 - * 열 위치를 얻고, 서피스 z와 원지반 z를 같은 자리에서 보간한다. - */ -function sampleRibbon( - ribbon: CorridorRibbon, - x: number, - y: number, -): { top: number; bottom: number } | null { - const ground = ribbon.groundZ; - if (!ground) return null; - const cols = ribbon.colCount; - const rows = ribbon.chainages.length; - let best: { distance: number; row: number; t: number } | null = null; - for (let row = 0; row < rows; row += 1) { - const a = row * cols * 3; - const b = (row * cols + cols - 1) * 3; - const dx = ribbon.positions[b] - ribbon.positions[a]; - const dy = ribbon.positions[b + 1] - ribbon.positions[a + 1]; - const lengthSq = dx * dx + dy * dy; - const t = - lengthSq > 1e-12 - ? Math.min( - 1, - Math.max( - 0, - ((x - ribbon.positions[a]) * dx + (y - ribbon.positions[a + 1]) * dy) / lengthSq, - ), - ) - : 0; - const distance = Math.hypot( - x - (ribbon.positions[a] + dx * t), - y - (ribbon.positions[a + 1] + dy * t), - ); - if (!best || distance < best.distance) best = { distance, row, t }; - } - if (!best) return null; - const span = best.t * (cols - 1); - const col = Math.min(cols - 2, Math.floor(span)); - const f = span - col; - const i = (best.row * cols + col) * 3; - const j = i + 3; - const g = best.row * cols + col; - return { - top: ribbon.positions[i + 2] + (ribbon.positions[j + 2] - ribbon.positions[i + 2]) * f, - bottom: ground[g] + (ground[g + 1] - ground[g]) * f - SKIRT_MARGIN_M, - }; -} - -/** `cut-wall` 커브를 성토면↔원지반 사이 판으로 세운다. */ -function buildWall( - ribbon: CorridorRibbon, - lines: ReadonlyArray>, -): Float64Array | null { - const tris: number[] = []; - for (const line of lines) { - for (let i = 0; i < line.length - 1; i += 1) { - const [x1, y1] = line[i]; - const [x2, y2] = line[i + 1]; - if (Math.hypot(x2 - x1, y2 - y1) < 1e-6) continue; - const a = sampleRibbon(ribbon, x1, y1); - const b = sampleRibbon(ribbon, x2, y2); - if (!a || !b) continue; - if (a.top - a.bottom < 1e-6 && b.top - b.bottom < 1e-6) continue; - tris.push(x1, y1, a.top, x2, y2, b.top, x2, y2, b.bottom); - tris.push(x1, y1, a.top, x2, y2, b.bottom, x1, y1, a.bottom); - } - } - return tris.length ? new Float64Array(tris) : null; -} - -/** - * 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다. - * 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다. - * - * 절취 영역은 비탈 투영선 ∪ 날개벽 사다리꼴이고, 실제로 잘린 자리를 **병합 커브** - * (`cut-merged`)로 함께 돌려준다 — 기존 커브는 그대로 두고 새 데이텀(+3m)에 얹는다. - */ -export function maskFillByPlanCurves( - ribbons: ReadonlyArray, - curves: ReadonlyArray, -): { ribbons: CorridorRibbon[]; curves: PlanCurve[]; walls: CorridorCutWall[] } { - const areasBySide = new Map(); - const planesBySide = new Map(); - for (const curve of curves) { - if (!CUT_SOURCES.includes(curve.source)) continue; - const bucket = areasBySide.get(curve.side) ?? []; - curve.loops.forEach((loop) => { - if (loop.length >= 4) bucket.push(areaOf(loop)); - }); - areasBySide.set(curve.side, bucket); - const planes = planesBySide.get(curve.side) ?? []; - if (!planes.some((plane) => plane.chainage === curve.setChainageM)) { - planes.push({ chainage: curve.setChainageM, planZ: curve.planZ, role: curve.role }); - } - planesBySide.set(curve.side, planes); - } - - const merged: PlanCurve[] = []; - const walls: CorridorCutWall[] = []; - // 측벽이 설 자리 — Plan이 커브로 넘겨준다(셀을 훑지 않는다). - const wallLinesBySide = new Map>>(); - for (const curve of curves) { - if (curve.source !== "cut-wall") continue; - const bucket = wallLinesBySide.get(curve.side) ?? []; - curve.loops.forEach((line) => { - if (line.length >= 2) bucket.push(line); - }); - wallLinesBySide.set(curve.side, bucket); - } - const cut = ribbons.map((ribbon) => { - const areas = areasBySide.get(ribbon.side); - // 패치는 `patchClip`이 붙은 것만 다룬다 — **절취 영역 안쪽만 남기는 반대 규칙**이다 - // (2026-08-27 사용자). 표식 없는 패치(BOX암거·기슭막이)는 종전대로 손대지 않는다. - const patchClip = ribbon.patch === true && ribbon.patchClip === true; - if (ribbon.kind !== "fill" || (ribbon.patch && !patchClip) || !areas?.length) return ribbon; - const rows = ribbon.chainages.length; - const cols = ribbon.colCount; - if (rows < 2 || cols < 2) return ribbon; - const mask = new Uint8Array((rows - 1) * (cols - 1)); - let masked = 0; - const tris: number[] = []; - const uvs: number[] = []; - const startChainage = ribbon.chainages[0]; - const point = (row: number, col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; - }; - const uvOf = (row: number, corner: Corner): [number, number] => { - const chainage = - ribbon.chainages[row] + (ribbon.chainages[row + 1] - ribbon.chainages[row]) * corner.b; - return [(chainage - startChainage) / 2, corner.a / Math.max(1, cols - 1)]; - }; - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - const ring: Corner[] = [ - { p: point(row, col), a: col, b: 0 }, - { p: point(row, col + 1), a: col + 1, b: 0 }, - { p: point(row + 1, col + 1), a: col + 1, b: 1 }, - { p: point(row + 1, col), a: col, b: 1 }, - ]; - // 귀퉁이가 하나도 안쪽이 아니면 그대로 둔다. - const inCount = ring.filter((corner) => - areas.some((area) => inside(area, corner.p[0], corner.p[1])), - ).length; - if (patchClip) { - // 반대 규칙 — 영역 **안**이 남고 **밖**이 지워진다. - if (inCount === 4) continue; - if (inCount === 0) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - continue; - } - const piece = clipCellByChord(areas, ring, "inside"); - if (!piece) { - if (coverage(areas, ring) < 0.5) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - } - continue; - } - mask[row * (cols - 1) + col] = 1; - masked += 1; - for (let k = 1; k < piece.kept.length - 1; k += 1) { - for (const corner of [piece.kept[0], piece.kept[k], piece.kept[k + 1]]) { - tris.push(corner.p[0], corner.p[1], corner.p[2]); - const [u, v] = uvOf(row, corner); - uvs.push(u, v); - } - } - continue; - } - if (inCount === 0) continue; - if (inCount === 4) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - continue; - } - const clip = clipCellByChord(areas, ring); - // 절단 실패는 **안쪽 면적 비율**로 가른다(2026-08-27 사용자: 면적 필터). - // 절반 이상 잠긴 셀은 지운다 — 종전처럼 통째로 남기면 경계에 미세 메쉬가 - // 줄줄이 남았다(실측: 좌 313·우 314셀, 4.50·2.17㎡). - if (!clip) { - if (coverage(areas, ring) >= 0.5) { - mask[row * (cols - 1) + col] = 1; - masked += 1; - } - continue; - } - mask[row * (cols - 1) + col] = 1; - masked += 1; - const kept = clip.kept; - for (let k = 1; k < kept.length - 1; k += 1) { - for (const corner of [kept[0], kept[k], kept[k + 1]]) { - tris.push(corner.p[0], corner.p[1], corner.p[2]); - const [u, v] = uvOf(row, corner); - uvs.push(u, v); - } - } - } - } - if (!masked) return ribbon; - // 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다. - // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. - const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; - const planes = planesBySide.get(ribbon.side) ?? []; - if (patchClip) { - return { - ...ribbon, - cellMask: mask, - ...(tris.length - ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } - : {}), - }; - } - traceMaskLoops(rows, cols, mask).forEach((raw) => { - if (raw.length < 4 || !planes.length) return; - const meanRow = raw.reduce((sum, [row]) => sum + row, 0) / raw.length; - const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))]; - const plane = planes.reduce((best, item) => - Math.abs(item.chainage - chainage) < Math.abs(best.chainage - chainage) ? item : best, - ); - const z = plane.planZ + MERGED_LIFT_M; - // **실좌표로 먼저 옮기고** 다듬는다 — 격자 인덱스로 다듬으면 휜 구간에서 코너를 - // 잘라먹어 그 밖 셀이 되살릴 영역에서 빠진다. - const loop = dropCollinear( - raw.map(([row, col]) => { - const [x, y] = point(row, col); - return [x, y, z] as [number, number, number]; - }), - ); - if (loop.length < 4) return; - merged.push({ - setChainageM: plane.chainage, - source: "cut-merged", - role: plane.role, - side, - planZ: z, - loops: [loop], - }); - }); - const wall = buildWall(ribbon, wallLinesBySide.get(ribbon.side) ?? []); - if (wall) walls.push({ side, positions: wall }); - return { - ...ribbon, - cellMask: mask, - ...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}), - }; - }); - return { ribbons: cut, curves: merged, walls }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts deleted file mode 100644 index 63cf7b044..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform.ts +++ /dev/null @@ -1,692 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform.ts - * 3D 예상형상 **새 방식** — 지형 메시 정점의 높이(z)만 설계면으로 옮기는 변형 코어 - * (계획서 115-1 · 검토 `docs/raw/요구_1008_4/검토_B05_3D_메시변형.md` 3절). - * - * 순수 기하 — Three.js 없음. 지형 높이는 부르는 쪽이 `groundAt`(모델 좌표)으로 준다. - * - * 규칙: - * · 행 = 코리도 빌드의 노면부 리본(차도 · 노견 · 측구) 행. 노면부 바깥 끝 = 앵커. - * · 정점 → (행 · 편거리) 역투영 — 0.2 m 행 중심 격자 색인 + 이웃 행 걷기. - * · 노면부 바깥에서 `z' = clamp(지형 z, 성토선 z, 절토선 z)`. 선 = 앵커 + 측점 사이 - * 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장. - * · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반. - * 못 찾으면 단면 끝까지만(115-5 P0-1) · 높이 변화는 단면 높이 + 1 m 까지. - * · 성토 비탈 끝은 종단으로 잇는다(115-7 smoothFillCatches) — 튐 · 못 닫힘 보간 · 원뿔 넓히기 · - * 바뀐 행은 비탈 끝 밖 2 m 덧댐. - * · 조각은 앞뒤 측점 중 하나라도 가진 행에만(`_Build.ts` 와 같은 규칙). - * · XY 는 움직이지 않는다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import { - classifyStation, - pieceKey, - type OffsetPoint, - type StationPieces, -} from "./B05_Profile_UI_Corridor_Station"; -import { - bracketControls, - buildPieceControls, - lerpPoints, - type PieceControl, -} from "./B05_Profile_UI_Corridor_Frames"; - -export type DeformSide = "left" | "right"; -export type DeformKind = "cut" | "fill"; - -/** 비탈 끝 찾기 한계(m) · 걸음(m) — 밴드 여유(80 m) 안쪽. */ -export const MAX_REACH_M = 60; -const SCAN_STEP_M = 0.25; -/** 높이 변화 안전 상한 = 단면 높이 + 이 값(m) — 폭주 구덩이 막음(115-5 P0-1 ④). */ -const SAFETY_M = 1; -/** 꺾임 브레이크라인으로 세울 단면 꺾임각(도). */ -const BEND_DEG = 8; -/** 이 안쪽 정점은 앵커 높이 그대로(노면부 끝 경계 정점). */ -const BOUNDARY_EPS_M = 0.01; -/** 역투영 되짚기 허용 오차(m) — 넘으면 곡선 안쪽 모호 자리로 보고 손대지 않는다. */ -const LOCATE_TOLERANCE_M = 0.5; -/** 행 중심 격자 칸(m) · 씨앗 행 간격(행 수). */ -const GRID_M = 4; -const SEED_EVERY = 5; -const WALK_LIMIT = 400; - -// ponytail: 성토 비탈 끝 종단 이음(115-7) 고정 값 — 이웃 ±3 m 중앙값 · 튐 = max(2 m, 25 %) · -// 원뿔 1 m/m · 비탈 끝 밖 덧댐 2 m · 이을 짧은 틈 3 m. 지형마다 안 맞으면 여기서 조절. -const FILL_WINDOW_M = 3; -const FILL_JUMP_M = 2; -const FILL_JUMP_RATIO = 0.25; -const FILL_CONE = 1; -const FILL_SKIRT_M = 2; -const FILL_BRIDGE_M = 3; - -const SIDES: readonly DeformSide[] = ["left", "right"]; -const KINDS: readonly DeformKind[] = ["fill", "cut"]; - -/** 노면부 바깥 끝 앵커 한 행 — 모델 좌표 [x, y, z]. */ -export interface DeformRow { - chainage_m: number; - left: [number, number, number]; - right: [number, number, number]; -} - -/** 비탈 단면(앵커 기준) — 바깥거리 s ≥ 0 오름차순 [s0, dz0, s1, dz1, …]. */ -export type DeformProfile = Float64Array; - -/** 한 행 · 한 측의 단면 출처 — 구조물 구간 고정 단면이면 연장 · 반대 종류를 막는다. */ -export interface RowProfiles { - fill: DeformProfile | null; - cut: DeformProfile | null; - /** 단면 끝 너머로 늘리지 않는다(BOX 「본체 밖 원지반」). */ - noExtend?: boolean; -} - -/** 행 · 측마다 단면을 내주는 자리 — 측점 단면 + 구조물 구간 단면(115-2). */ -export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles; - -/** - * 구조물 구간 단면(115-2) — 소유 측점 실루엣의 상부 포락선(앵커 기준 상대 · 측점 단면과 - * 같은 꼴 · 같은 열 수). 구간(전/후 몫) 안은 이 단면 그대로, 밖은 이웃 측점까지 테이퍼. - */ -export interface StructureSlope { - chainage_m: number; - side: DeformSide; - kind: DeformKind; - span: { beforeM: number; afterM: number }; - points: OffsetPoint[]; - /** 실루엣이 구조물에서 끝난다(BOX) — 구간 안은 단면 끝 너머를 원지반으로 둔다. */ - endsAtStructure: boolean; -} - -/** 성토 금지 고리(115-2 · 날개 안쪽 유로 · 에이프런) — 안쪽 정점은 원지반(또는 천장) 위로 못 올린다. */ -export interface DeformLimit { - /** 모델 좌표 닫힌 고리. */ - loop: Array<[number, number]>; - /** 바닥판 상단 등 — 원지반이 이보다 낮으면 여기까지는 올린다. null 이면 원지반. */ - ceiling: number | null; -} - -/** 정점 하나의 역투영 결과. */ -export interface DeformLocation { - row: number; - t: number; - side: DeformSide; - /** 노면부 바깥 끝에서 잰 바깥거리(m) — 음수면 노면부 안. */ - outward: number; -} - -/** 변형 결과 — 높이와 자리(0 원지반 · 1 성토 · 2 절토). 색은 자리로 칠한다. */ -export interface DeformPoint { - z: number; - zone: 0 | 1 | 2; -} - -export interface DeformField { - rows: DeformRow[]; - locate: (x: number, y: number) => DeformLocation | null; - /** 변형된 z — 대상이 아니면 받은 z 그대로. */ - deformZ: (x: number, y: number, z: number) => number; - deformAt: (x: number, y: number, z: number) => DeformPoint; - /** 행 · 측(0 좌 · 1 우) · 종류(0 성토 · 1 절토)의 비탈 끝 바깥거리(m) — 0 이면 비탈 없음. */ - catchAt: (row: number, side: number, kind: number) => number; - /** 비탈 끝 안 단면 꺾임 — [열 번호, 바깥거리] (브레이크라인). */ - bendsAt: (row: number, side: number, kind: number) => ReadonlyArray<[number, number]>; - /** 행 앵커에서 바깥으로 s 만큼 간 평면 자리(모델 좌표). */ - pointAt: (row: number, side: number, s: number) => [number, number]; -} - -/** 단면 높이 — s 가 끝을 넘으면 마지막 구배로 늘린다(`noExtend`면 null). */ -export function profileZ(profile: DeformProfile, s: number, noExtend = false): number | null { - const count = profile.length / 2; - if (count < 1) return null; - if (s <= profile[0]) return profile[1]; - for (let i = 1; i < count; i += 1) { - const s1 = profile[i * 2]; - if (s <= s1) { - const s0 = profile[i * 2 - 2]; - const span = s1 - s0; - const t = span <= 1e-9 ? 1 : (s - s0) / span; - return profile[i * 2 - 1] + (profile[i * 2 + 1] - profile[i * 2 - 1]) * t; - } - } - if (noExtend) return null; - // 마지막 구배 — 끝에서 거꾸로 짚어 길이가 있는 마디를 쓴다(`_Station.ts` trimSlopeToGround 연장과 같은 규칙). - const last = count - 1; - for (let i = last - 1; i >= 0; i -= 1) { - const run = profile[last * 2] - profile[i * 2]; - if (run > 1e-3) { - const grade = (profile[last * 2 + 1] - profile[i * 2 + 1]) / run; - return profile[last * 2 + 1] + grade * (s - profile[last * 2]); - } - } - return profile[last * 2 + 1]; -} - -/** 상대 단면 점(편거리 · 표고, 도로측 원점) → 바깥거리 오름차순 단면. */ -export function outwardProfile( - points: ReadonlyArray, - side: DeformSide, -): DeformProfile | null { - if (points.length < 2) return null; - const sign = side === "left" ? 1 : -1; - const ordered = side === "left" ? points : [...points].reverse(); - const origin = ordered[0]; - const profile = new Float64Array(ordered.length * 2); - ordered.forEach((point, i) => { - profile[i * 2] = Math.max(0, (point.offset_m - origin.offset_m) * sign); - profile[i * 2 + 1] = point.elevation_m - origin.elevation_m; - }); - return profile; -} - -/** 빌드 노면부 리본에서 행마다 좌우 바깥 끝(측구 > 노견 > 차도)을 모은다. */ -export function roadRows(build: CorridorBuildResult): DeformRow[] { - const RANK: Record = { carriageway: 1, shoulder: 2, ditch: 3 }; - const byKey = new Map< - number, - { - chainage: number; - left?: DeformRow["left"]; - lr: number; - right?: DeformRow["right"]; - rr: number; - } - >(); - for (const ribbon of build.ribbons) { - const rank = RANK[ribbon.kind]; - if (!rank || ribbon.patch) continue; - const cols = ribbon.colCount; - ribbon.chainages.forEach((chainage, row) => { - const key = Math.round(chainage * 1000); - const entry = byKey.get(key) ?? { chainage, lr: 0, rr: 0 }; - const at = (col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; - }; - if (ribbon.side !== "right" && rank > entry.lr) { - entry.left = at(cols - 1); - entry.lr = rank; - } - if (ribbon.side !== "left" && rank > entry.rr) { - entry.right = at(0); - entry.rr = rank; - } - byKey.set(key, entry); - }); - } - return [...byKey.entries()] - .sort((a, b) => a[0] - b[0]) - .flatMap(([, entry]) => - entry.left && entry.right - ? [{ chainage_m: entry.chainage, left: entry.left, right: entry.right }] - : [], - ); -} - -/** - * 측점 비탈 단면으로 내는 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). 구조물 단면을 주면 - * 그 측점의 그 측 · 그 종류는 구조물 단면으로 갈아 끼우고 전/후 몫을 고정 구간으로 세운다 - * (구간 끝 → 이웃 측점 테이퍼). 고정 구간 안은 실루엣 종류만 남긴다. - */ -export function stationProfileSource( - sections: ReadonlyArray, - slopes: ReadonlyArray = [], -): ProfileSource { - const stations = sections - .map(classifyStation) - .filter((s): s is StationPieces => s !== null) - .sort((a, b) => a.chainage_m - b.chainage_m); - const slopeOf = (station: StationPieces, side: DeformSide, kind: DeformKind) => - slopes.find( - (slope) => - slope.side === side && - slope.kind === kind && - Math.abs(slope.chainage_m - station.chainage_m) < 1e-6, - ); - const controls = new Map(); - SIDES.forEach((side) => - KINDS.forEach((kind) => { - const key = pieceKey(kind, side); - controls.set( - key, - buildPieceControls( - stations, - side, - (station) => slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(key), - (station) => slopeOf(station, side, kind)?.span, - ), - ); - }), - ); - const pick = (station: StationPieces, side: DeformSide, kind: DeformKind) => - slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(pieceKey(kind, side)); - /** 행을 감싼 앞뒤 측점 — 범위 밖은 끝 측점. */ - const around = (chainageM: number): [StationPieces, StationPieces] => { - let i = 0; - while (i < stations.length - 2 && stations[i + 1].chainage_m < chainageM) i += 1; - const next = stations[Math.min(i + 1, stations.length - 1)]; - if (chainageM <= stations[i].chainage_m) return [stations[i], stations[i]]; - return chainageM >= next.chainage_m ? [next, next] : [stations[i], next]; - }; - const at = (side: DeformSide, kind: DeformKind, chainageM: number): DeformProfile | null => { - if (!stations.length) return null; - // 앞뒤 측점 둘 다 그 조각이 없으면 이 행에는 없다(`_Build.ts` 규칙 · 115-5 P0-1 ②). - const [s0, s1] = around(chainageM); - const own0 = pick(s0, side, kind); - const own1 = pick(s1, side, kind); - if (!own0 && !own1) return null; - const list = controls.get(pieceKey(kind, side)); - // 그 측 그 종류를 가진 측점이 하나뿐 — 그 단면 그대로. - if (!list) return outwardProfile((own0 ?? own1) as OffsetPoint[], side); - const { a, b, t } = bracketControls(list, chainageM); - const points = a === b ? a.points : lerpPoints(a.points, b.points, t); - return outwardProfile(points, side); - }; - return (chainageM, side) => { - const fill = at(side, "fill", chainageM); - const cut = at(side, "cut", chainageM); - const fixed = slopes.find( - (slope) => - slope.side === side && - chainageM >= slope.chainage_m - slope.span.beforeM - 1e-9 && - chainageM <= slope.chainage_m + slope.span.afterM + 1e-9, - ); - if (!fixed) return { fill, cut }; - return { - fill: fixed.kind === "fill" ? fill : null, - cut: fixed.kind === "cut" ? cut : null, - noExtend: fixed.endsAtStructure, - }; - }; -} - -/** - * 성토 비탈 끝 종단 이음(115-7) — 한 측 성토 열. 행마다 따로 찾은 비탈 끝이 이웃과 튀면(골짜기 · - * 계단 DTM · 못 닫힘) 두 행 혼합이 행 사이에 수직 벽(지느러미 · 빗살 구덩이)을 세운다. - * 튐 · 못 닫힘 행 → 이웃 좋은 행 보간 → 원뿔 넓히기(이웃 차 1 m/m 안). 측점 행은 날것(못 닫힘 빼고). - * 비탈 끝 0 행은 구간을 끊는다 — 단면은 있는데 앵커에서 막힌 3 m 안 틈만 잇는다. - * `open` = 못 닫힘 · `station` = 측점 행 · `shaped` = 그 행에 성토 단면 있음(모두 0/1). - */ -export function smoothFillCatches( - chainages: ArrayLike, - raw: ArrayLike, - open: ArrayLike, - station: ArrayLike, - shaped: ArrayLike, -): Float64Array { - const n = raw.length; - const out = Float64Array.from(raw); - const on = new Uint8Array(n); - const bridged = new Uint8Array(n); - for (let r = 0; r < n; r += 1) on[r] = raw[r] > 0 ? 1 : 0; - for (let r = 1; r < n; r += 1) { - if (on[r] || !on[r - 1]) continue; - let e = r; - while (e < n && !on[e] && shaped[e]) e += 1; - if (e < n && on[e] && chainages[e - 1] - chainages[r] <= FILL_BRIDGE_M) { - for (let k = r; k < e; k += 1) on[k] = bridged[k] = 1; - } - r = e; - } - for (let start = 0; start < n;) { - if (!on[start]) { - start += 1; - continue; - } - let end = start; - while (end + 1 < n && on[end + 1]) end += 1; - const trusted = (k: number) => !open[k] && !bridged[k]; - const good = new Uint8Array(end - start + 1); - let lo = start; - let hi = start; - for (let r = start; r <= end; r += 1) { - if (station[r] && trusted(r)) { - good[r - start] = 1; - continue; - } - while (chainages[lo] < chainages[r] - FILL_WINDOW_M) lo += 1; - while (hi < end && chainages[hi + 1] <= chainages[r] + FILL_WINDOW_M) hi += 1; - const near: number[] = []; - for (let k = lo; k <= hi; k += 1) if (trusted(k)) near.push(raw[k]); - if (!near.length) { - good[r - start] = trusted(r) ? 1 : 0; - continue; - } - near.sort((a, b) => a - b); - const median = near[near.length >> 1]; - const jump = Math.max(FILL_JUMP_M, median * FILL_JUMP_RATIO); - good[r - start] = trusted(r) && Math.abs(raw[r] - median) <= jump ? 1 : 0; - } - // 튐 · 못 닫힘 — 앞뒤 좋은 행을 노선 길이로 보간(한쪽뿐이면 그 값 · 없으면 날것). - let prev = -1; - for (let r = start; r <= end; r += 1) { - if (good[r - start]) { - prev = r; - continue; - } - let next = r + 1; - while (next <= end && !good[next - start]) next += 1; - if (prev >= 0 && next <= end) { - const t = (chainages[r] - chainages[prev]) / (chainages[next] - chainages[prev] || 1); - out[r] = raw[prev] + (raw[next] - raw[prev]) * t; - } else if (prev >= 0) out[r] = raw[prev]; - else if (next <= end) out[r] = raw[next]; - } - // 원뿔 넓히기 — c'_r = max_j(c_j − 1·|s_r − s_j|) 를 앞뒤 두 번 훑어 낸다. 늘린 몫은 측점 닻 - // (측점 값 + 1 m/m)까지만 — 먼 큰 값이 측점까지 번지지 않게. 보간 값보다 줄이지는 않는다. - const base = out.slice(start, end + 1); - const cap = new Float64Array(end - start + 1).fill(Infinity); - for (let r = start + 1; r <= end; r += 1) { - out[r] = Math.max(out[r], out[r - 1] - FILL_CONE * (chainages[r] - chainages[r - 1])); - } - for (let r = end - 1; r >= start; r -= 1) { - out[r] = Math.max(out[r], out[r + 1] - FILL_CONE * (chainages[r + 1] - chainages[r])); - } - for (const step of [1, -1]) { - let anchor = -1; - for (let r = step > 0 ? start : end; r >= start && r <= end; r += step) { - if (station[r] && trusted(r)) anchor = r; - if (anchor < 0) continue; - const reach = raw[anchor] + FILL_CONE * Math.abs(chainages[r] - chainages[anchor]); - cap[r - start] = Math.min(cap[r - start], reach); - } - } - for (let r = start; r <= end; r += 1) { - out[r] = Math.max(base[r - start], Math.min(out[r], cap[r - start])); - } - start = end + 1; - } - return out; -} - -/** 점이 고리 안인가(광선 투사). */ -function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const [xi, yi] = loop[i]; - const [xj, yj] = loop[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 변형 장 만들기. 노면부 행이 2개 미만이면 null. - * `groundAt` = 원지반 높이(모델 좌표) — 비탈 끝(첫 지반 교차점) 찾기에만 쓴다. - */ -export function buildDeformField( - build: CorridorBuildResult, - source: ProfileSource, - groundAt: (x: number, y: number) => number | null, - limits: ReadonlyArray = [], -): DeformField | null { - const rows = roadRows(build); - const n = rows.length; - if (n < 2) return null; - const mx = new Float64Array(n); - const my = new Float64Array(n); - const ux = new Float64Array(n); - const uy = new Float64Array(n); - const hw = new Float64Array(n); - /** [행 × 측 × 종류] 단면 · 비탈 끝 거리. 측 = 0 좌 · 1 우, 종류 = 0 성토 · 1 절토. */ - const profiles: Array = new Array(n * 4).fill(null); - const catches = new Float64Array(n * 4); - const heights = new Float64Array(n * 4); - const bends: Array> = new Array(n * 4); - const noExtend = new Uint8Array(n * 2); - /** 비탈 끝을 단면 끝까지 못 찾음(못 닫힘). */ - const open = new Uint8Array(n * 4); - let reachMax = 0; - rows.forEach((row, r) => { - const dx = row.left[0] - row.right[0]; - const dy = row.left[1] - row.right[1]; - const width = Math.hypot(dx, dy) || 1e-9; - mx[r] = (row.left[0] + row.right[0]) / 2; - my[r] = (row.left[1] + row.right[1]) / 2; - ux[r] = dx / width; - uy[r] = dy / width; - hw[r] = width / 2; - SIDES.forEach((side, si) => { - const anchor = side === "left" ? row.left : row.right; - const sign = side === "left" ? 1 : -1; - const shapes = source(row.chainage_m, side); - noExtend[r * 2 + si] = shapes.noExtend ? 1 : 0; - KINDS.forEach((kind, ki) => { - const profile = shapes[kind]; - const slot = r * 4 + si * 2 + ki; - profiles[slot] = profile; - bends[slot] = []; - if (!profile) return; - const count = profile.length / 2; - for (let i = 0; i < count; i += 1) { - heights[slot] = Math.max(heights[slot], Math.abs(profile[i * 2 + 1])); - } - // 비탈 끝 = 앵커에서 바깥으로 걸어 지반과 처음 만나는 자리. 성토는 지반이 선 위로, - // 절토는 아래로 넘어가는 곳. 앵커에서 이미 반대편이면 그 종류는 이 행에 없다(0). - const beyond = (s: number): number | null => { - const line = profileZ(profile, s, shapes.noExtend); - if (line === null) return null; - const ground = groundAt(anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s); - if (ground === null) return null; - const gap = anchor[2] + line - ground; - return kind === "fill" ? gap : -gap; - }; - let previous = beyond(0); - let reach = 0; - if (previous !== null && previous > 0) { - let found = false; - for (let s = SCAN_STEP_M; s <= MAX_REACH_M + 1e-9; s += SCAN_STEP_M) { - const now = beyond(s); - if (now === null) { - reach = s - SCAN_STEP_M; - found = true; - break; - } - if (now <= 0) { - reach = s - SCAN_STEP_M + (SCAN_STEP_M * previous) / (previous - now); - found = true; - break; - } - previous = now; - } - // 못 찾으면 단면 끝까지만 — 마지막 구배로 60 m 까지 깎던 폭주 구덩이(115-5 P0-1 ①). - const end = profile[(count - 1) * 2]; - if (!found) reach = end; - // 못 닫힘(115-7) — 단면 끝(측점 = B06 비탈 끝) 너머 튐 폭보다 멀리서야 닫힌 성토. 연장 구배를 - // 안전 상한이 눌러 원지반 위 띠(지느러미)가 되던 행 — 이웃 좋은 행으로 보간한다. - const slack = Math.max(FILL_JUMP_M, end * FILL_JUMP_RATIO); - if (!found || (kind === "fill" && reach > end + slack)) open[slot] = 1; - } - catches[slot] = reach; - }); - }); - }); - - // 성토 비탈 끝 종단 이음(115-7) — 짧아진 행 · 못 닫힘 행은 비탈 끝 밖을 덧댄다. - const chainages = rows.map((row) => row.chainage_m); - const station = new Uint8Array(n); - for (const section of build.sections ?? []) { - let r = 0; - while (r + 1 < n && chainages[r + 1] <= section.chainage_m) r += 1; - if (r + 1 < n && chainages[r + 1] - section.chainage_m < section.chainage_m - chainages[r]) { - r += 1; - } - if (Math.abs(chainages[r] - section.chainage_m) < 0.15) station[r] = 1; - } - const skirt = new Uint8Array(n * 4); - for (let si = 0; si < 2; si += 1) { - const pick = (of: (slot: number) => T) => rows.map((_, r) => of(r * 4 + si * 2)); - const raw = pick((slot) => catches[slot]); - const final = smoothFillCatches( - chainages, - raw, - pick((slot) => open[slot]), - station, - pick((slot) => (profiles[slot] ? 1 : 0)), - ); - final.forEach((c, r) => { - const slot = r * 4 + si * 2; - catches[slot] = c; - if (c > 0 && (open[slot] || c < raw[r] - 0.01 || raw[r] <= 0)) skirt[slot] = 1; - }); - } - for (let slot = 0; slot < n * 4; slot += 1) { - const reach = catches[slot]; - reachMax = Math.max(reachMax, reach + (skirt[slot] ? FILL_SKIRT_M : 0)); - const profile = profiles[slot]; - if (!profile) continue; - const count = profile.length / 2; - // 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만. - for (let i = 1; i < count - 1; i += 1) { - const s = profile[i * 2]; - if (s >= reach) break; - const a0 = Math.atan2(profile[i * 2 + 1] - profile[i * 2 - 1], s - profile[i * 2 - 2]); - const a1 = Math.atan2(profile[i * 2 + 3] - profile[i * 2 + 1], profile[i * 2 + 2] - s); - if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) bends[slot].push([i, s]); - } - } - - // 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다. - let maxHalf = 0; - hw.forEach((value) => (maxHalf = Math.max(maxHalf, value))); - const radius = maxHalf + reachMax + GRID_M; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (let r = 0; r < n; r += 1) { - minX = Math.min(minX, mx[r]); - maxX = Math.max(maxX, mx[r]); - minY = Math.min(minY, my[r]); - maxY = Math.max(maxY, my[r]); - } - minX -= radius; - minY -= radius; - const cols = Math.max(1, Math.ceil((maxX + radius - minX) / GRID_M)); - const gridRows = Math.max(1, Math.ceil((maxY + radius - minY) / GRID_M)); - const seed = new Int32Array(cols * gridRows).fill(-1); - const seedDistance = new Float64Array(cols * gridRows).fill(Infinity); - const span = Math.ceil(radius / GRID_M); - const seeds: number[] = []; - for (let r = 0; r < n; r += SEED_EVERY) seeds.push(r); - if (seeds[seeds.length - 1] !== n - 1) seeds.push(n - 1); - for (const r of seeds) { - const c0 = Math.floor((mx[r] - minX) / GRID_M); - const g0 = Math.floor((my[r] - minY) / GRID_M); - for (let g = Math.max(0, g0 - span); g <= Math.min(gridRows - 1, g0 + span); g += 1) { - for (let c = Math.max(0, c0 - span); c <= Math.min(cols - 1, c0 + span); c += 1) { - const d = Math.hypot(minX + (c + 0.5) * GRID_M - mx[r], minY + (g + 0.5) * GRID_M - my[r]); - if (d > radius || d >= seedDistance[g * cols + c]) continue; - seedDistance[g * cols + c] = d; - seed[g * cols + c] = r; - } - } - } - - const along = (r: number, x: number, y: number): number => - (x - mx[r]) * uy[r] - (y - my[r]) * ux[r]; - const lateral = (r: number, x: number, y: number): number => - (x - mx[r]) * ux[r] + (y - my[r]) * uy[r]; - - // ponytail: 칸의 가장 가까운 씨앗 하나로 걷는다 — 헤어핀 두 가지가 한 칸에 겹치면 칸 중심에 - // 가까운 쪽(대개 편거리 작은 쪽)을 탄다. 어긋나면 씨앗 둘을 견주는 쪽으로 넓힌다. - function locate(x: number, y: number): DeformLocation | null { - const c = Math.floor((x - minX) / GRID_M); - const g = Math.floor((y - minY) / GRID_M); - if (c < 0 || g < 0 || c >= cols || g >= gridRows) return null; - let k = seed[g * cols + c]; - if (k < 0) return null; - let steps = 0; - while (k > 0 && along(k, x, y) < 0 && steps < WALK_LIMIT) { - k -= 1; - steps += 1; - } - while (k + 1 < n && along(k + 1, x, y) >= 0 && steps < WALK_LIMIT) { - k += 1; - steps += 1; - } - if (k + 1 >= n) return null; - const a0 = along(k, x, y); - const a1 = along(k + 1, x, y); - if (a0 < 0 || a1 >= 0) return null; - const t = a0 / (a0 - a1); - const o = lateral(k, x, y) * (1 - t) + lateral(k + 1, x, y) * t; - // 되짚기 — 보간 행 위 그 편거리 자리가 정점과 같아야 한다(곡선 안쪽 너머는 버린다). - const cx = mx[k] + (mx[k + 1] - mx[k]) * t; - const cy = my[k] + (my[k + 1] - my[k]) * t; - const vx = ux[k] + (ux[k + 1] - ux[k]) * t; - const vy = uy[k] + (uy[k + 1] - uy[k]) * t; - const norm = Math.hypot(vx, vy) || 1; - if (Math.hypot(cx + (vx / norm) * o - x, cy + (vy / norm) * o - y) > LOCATE_TOLERANCE_M) { - return null; - } - const half = hw[k] + (hw[k + 1] - hw[k]) * t; - return { row: k, t, side: o >= 0 ? "left" : "right", outward: Math.abs(o) - half }; - } - - /** 한 행 기준 변형 높이 — 비탈 끝 안에서만 성토선 위로 · 절토선 아래로 맞춘다. */ - function rowZ(r: number, si: number, s: number, z: number): number { - const anchor = si === 0 ? rows[r].left : rows[r].right; - if (s <= BOUNDARY_EPS_M) return anchor[2]; - let out = z; - const free = noExtend[r * 2 + si] === 1; - KINDS.forEach((kind, ki) => { - const slot = r * 4 + si * 2 + ki; - const profile = profiles[slot]; - const reach = catches[slot]; - if (!profile || reach <= 0) return; - // 덧댐(115-7) — 이은 비탈 끝 밖 2 m 는 비탈 끝 높이에서 원지반으로 내려 수직 면을 없앤다. - if (s >= reach && (!skirt[slot] || s >= reach + FILL_SKIRT_M)) return; - const pad = s < reach ? 0 : (s - reach) / FILL_SKIRT_M; - const rel = profileZ(profile, Math.min(s, reach), free); - if (rel === null) return; - let line = anchor[2] + rel; - line += (z - line) * pad; - // 안전 상한(115-5 P0-1 ④) — 단면 높이 + 1 m 넘게는 옮기지 않는다. 구조물에서 끝나는 - // 단면(BOX)은 지반까지 안 내려가 단면 높이가 기준이 못 된다. - const cap = heights[slot] + SAFETY_M; - if (!free && Math.abs(line - z) > cap) line = z + Math.sign(line - z) * cap; - if (kind === "fill" ? out < line : out > line) out = line; - }); - return out; - } - - /** 그 자리의 비탈 종류 — 앞뒤 행 비탈 끝을 보간한 선 안이면 그 종류(비탈 끝 브레이크라인과 같은 선). */ - function zoneOf(r: number, si: number, s: number, t: number): 0 | 1 | 2 { - for (let ki = 0; ki < 2; ki += 1) { - const a = catches[r * 4 + si * 2 + ki]; - const b = catches[(r + 1) * 4 + si * 2 + ki]; - if (s < a * (1 - t) + b * t) return ki === 0 ? 1 : 2; - } - return 0; - } - - function deformAt(x: number, y: number, z: number): DeformPoint { - const at = locate(x, y); - if (!at || at.outward > reachMax) return { z, zone: 0 }; - const si = at.side === "left" ? 0 : 1; - const s = Math.max(0, at.outward); - let out = rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t; - for (const limit of limits) { - if (out > z && insideLoop(limit.loop, x, y)) { - out = Math.min(out, Math.max(z, limit.ceiling ?? z)); - } - } - return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s, at.t) }; - } - - return { - rows, - locate, - deformAt, - deformZ: (x, y, z) => deformAt(x, y, z).z, - catchAt: (r, si, ki) => catches[r * 4 + si * 2 + ki], - bendsAt: (r, si, ki) => bends[r * 4 + si * 2 + ki] ?? [], - pointAt: (r, si, s) => { - const anchor = si === 0 ? rows[r].left : rows[r].right; - const sign = si === 0 ? 1 : -1; - return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s]; - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts deleted file mode 100644 index d2a71b246..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Breakline.ts +++ /dev/null @@ -1,612 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Breakline.ts - * 새 방식 지형의 **브레이크라인 국소 재삼각분할**(계획서 115-5 P1-3). - * - * 노면 끝(앵커) · 비탈 끝 · 단면 꺾임 · 구조물 구멍 선을 지형 메시에 끼워 넣는다. - * · 선이 걸친 삼각형만 그 삼각형 안 선 조각으로 면을 나눠(평면 그래프 면 걷기) - * `ShapeUtils.triangulateShape`(earcut)로 다시 삼각분할한다. - * · 삼각형 변과 선의 교점은 **변 꼭짓점 번호 순서로 정한 식**으로 내 이웃 삼각형과 - * 비트까지 같다 — 변형 뒤에도 T자 균열이 없다(`_Clip.ts` 무한직선 절단 대체). - * · 지울 자리(노면부 · 구멍) 면은 버리고, 남은 면은 무게중심 자리로 한 색(성토 · 절토 · - * 원지반)을 칠한다 — 색 경계 = 비탈 끝 선. - * · 손댈 자리(`touched` — 행별 비탈 끝 + 5 m 띠) 밖 삼각형은 번호만 넘긴다(P2-5). - * - * 원본 geometry 는 읽기만 한다 — 새 위치 · 속성 배열을 만든다. 법선은 만들지 않는다(`_Clip.ts`). - * ========================================================================== */ - -import * as THREE from "three"; -import type { P2 } from "./B05_Profile_UI_Corridor_Region"; - -/** 브레이크라인 하나 — 씬 평면(x, z) 꺾은선. 닫힌 고리는 끝점 = 첫 점. */ -export type Breakline = P2[]; - -/** 변형 결과(씬 Y) · 자리(0 원지반 · 1 성토 · 2 절토). */ -export interface BandPoint { - y: number; - zone: 0 | 1 | 2; -} - -export interface BandContext { - lines: ReadonlyArray; - /** 지울 자리(노면부 · 구조물 구멍). */ - removed: (p: P2) => boolean; - /** 손댈 자리 — 밖 삼각형은 그대로 넘긴다. */ - touched: (p: P2) => boolean; - /** 씬 좌표 정점 → 변형 높이 · 자리. */ - deform: (x: number, y: number, z: number) => BandPoint; - /** 자리별 색(r, g, b · 0~1). */ - tint: (zone: 1 | 2) => [number, number, number]; -} - -export interface BandStats { - split: number; - /** 선이 걸쳤는데 못 나눈 삼각형(퇴화) — 통째로 둔다. */ - fallback: number; - moved: number; - raised: number; - lowered: number; - maxRaiseM: number; - maxCutM: number; -} - -const CELL_M = 2; -/** 점 같음 판정(m) — 교점 · 꼭짓점 물림. */ -const SNAP_M = 1e-6; -/** 이보다 작게 움직이면 움직였다고 세지 않는다(m). */ -const MOVE_MIN_M = 0.01; - -/** 선분 격자 색인 — 선분 [px, pz, qx, qz] 와 칸별 선분 번호. */ -class SegmentGrid { - readonly segs: number[] = []; - private cells = new Map(); - private minX = Infinity; - private minZ = Infinity; - private cols = 1; - - constructor(lines: ReadonlyArray) { - for (const line of lines) { - for (let i = 1; i < line.length; i += 1) { - const p = line[i - 1]; - const q = line[i]; - if (Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M) continue; - this.segs.push(p.x, p.z, q.x, q.z); - this.minX = Math.min(this.minX, p.x, q.x); - this.minZ = Math.min(this.minZ, p.z, q.z); - } - } - let maxX = -Infinity; - for (let s = 0; s < this.segs.length; s += 4) - maxX = Math.max(maxX, this.segs[s], this.segs[s + 2]); - this.cols = Math.max(1, Math.ceil((maxX - this.minX) / CELL_M) + 1); - for (let s = 0; s < this.segs.length; s += 4) { - const [c0, c1] = this.span(this.segs[s], this.segs[s + 2], this.minX); - const [r0, r1] = this.span(this.segs[s + 1], this.segs[s + 3], this.minZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - const key = r * this.cols + c; - const list = this.cells.get(key); - if (list) list.push(s / 4); - else this.cells.set(key, [s / 4]); - } - } - } - } - - private span(a: number, b: number, origin: number): [number, number] { - return [ - Math.floor((Math.min(a, b) - origin) / CELL_M), - Math.floor((Math.max(a, b) - origin) / CELL_M), - ]; - } - - /** 상자에 걸칠 수 있는 선분 번호(중복 없음). */ - near(minX: number, maxX: number, minZ: number, maxZ: number): number[] { - if (!this.segs.length) return []; - const [c0, c1] = this.span(minX, maxX, this.minX); - const [r0, r1] = this.span(minZ, maxZ, this.minZ); - const out: number[] = []; - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - const list = this.cells.get(r * this.cols + c); - if (list) for (const s of list) if (!out.includes(s)) out.push(s); - } - } - return out; - } -} - -const cross = (ax: number, az: number, bx: number, bz: number): number => ax * bz - az * bx; - -function polygonArea(points: ReadonlyArray): number { - let area = 0; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - area += points[j].x * points[i].z - points[i].x * points[j].z; - } - return area / 2; -} - -function insidePolygon(points: ReadonlyArray, p: P2): boolean { - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const a = points[i]; - const b = points[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) - hit = !hit; - } - return hit; -} - -/** 삼각형 안 선 조각 한 끝 — 자리와, 경계 위면 어느 변(0~2) · 꼭짓점(3~5)인가. */ -interface PieceEnd { - u: number; - p: P2; - on: number[]; -} - -/** - * 삼각형(꼭짓점 번호 ids)을 선분들로 나눈 면들 — 면마다 [바깥 고리, ...구멍 고리]. - * 걸친 선이 없으면 null · 퇴화로 못 나누면 변 위 마디만 품은 삼각형 한 면(`fallback`). - */ -export function splitTriangle( - corners: [P2, P2, P2], - ids: [number, number, number], - segIds: ReadonlyArray, - segs: ReadonlyArray, -): Array<{ contour: P2[]; holes: P2[][]; fallback?: boolean }> | null { - const pieces: Array<{ a: PieceEnd; b: PieceEnd; cuts: Array<{ t: number; p: P2 }> }> = []; - /** 변 위 교점 전부 — 조각이 안 남아도(변에 스친 선) 마디로 둔다. 이웃 삼각형도 같은 교점을 - * 같은 식으로 내므로 양쪽이 늘 같은 마디를 갖는다(T자 균열 0). */ - const edgeHits: Array<{ e: number; p: P2 }> = []; - const triArea = polygonArea(corners); - if (Math.abs(triArea) < 1e-10) return null; - const sign = Math.sign(triArea); - const inside = (p: P2): boolean => { - for (let e = 0; e < 3; e += 1) { - const a = corners[e]; - const b = corners[(e + 1) % 3]; - if (sign * cross(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z) <= 0) return false; - } - return true; - }; - for (const s of segIds) { - const P = { x: segs[s * 4], z: segs[s * 4 + 1] }; - const Q = { x: segs[s * 4 + 2], z: segs[s * 4 + 3] }; - const ends: PieceEnd[] = []; - let collinear = false; - for (let e = 0; e < 3 && !collinear; e += 1) { - // 변은 꼭짓점 번호 작은 쪽 → 큰 쪽으로 — 이웃 삼각형과 같은 식 · 같은 값. - const swap = ids[e] > ids[(e + 1) % 3]; - const lo = corners[swap ? (e + 1) % 3 : e]; - const hi = corners[swap ? e : (e + 1) % 3]; - const ex = hi.x - lo.x; - const ez = hi.z - lo.z; - const length = Math.hypot(ex, ez); - const d1 = cross(ex, ez, P.x - lo.x, P.z - lo.z) / length; - const d2 = cross(ex, ez, Q.x - lo.x, Q.z - lo.z) / length; - if (Math.abs(d1) < SNAP_M && Math.abs(d2) < SNAP_M) collinear = true; - if ((d1 > 0 && d2 > 0) || (d1 < 0 && d2 < 0) || d1 === d2) continue; - const u = d1 / (d1 - d2); - const x = P.x + (Q.x - P.x) * u; - const z = P.z + (Q.z - P.z) * u; - const w = ((x - lo.x) * ex + (z - lo.z) * ez) / (length * length); - if (w * length < -SNAP_M || (w - 1) * length > SNAP_M) continue; - const corner = - w * length < SNAP_M - ? swap - ? (e + 1) % 3 - : e - : (1 - w) * length < SNAP_M - ? swap - ? e - : (e + 1) % 3 - : -1; - if (corner >= 0) { - ends.push({ u, p: corners[corner], on: [corner + 3, corner, (corner + 2) % 3] }); - } else { - ends.push({ u, p: { x, z }, on: [e] }); - edgeHits.push({ e, p: { x, z } }); - } - } - if (collinear) continue; - if (inside(P)) ends.push({ u: 0, p: P, on: [] }); - if (inside(Q)) ends.push({ u: 1, p: Q, on: [] }); - if (ends.length < 2) continue; - ends.sort((m, n) => m.u - n.u); - const a = ends[0]; - const b = ends[ends.length - 1]; - if (Math.hypot(a.p.x - b.p.x, a.p.z - b.p.z) < SNAP_M) continue; - // 두 끝이 같은 변 위 — 경계를 따라가는 선이라 나눌 것이 없다. - if (a.on.some((k) => k < 3 && b.on.includes(k))) continue; - pieces.push({ a, b, cuts: [] }); - } - if (!pieces.length && !edgeHits.length) return null; - // 선 조각끼리 교차 — 두 조각 모두에 마디를 넣는다. - for (let i = 0; i < pieces.length; i += 1) { - for (let j = i + 1; j < pieces.length; j += 1) { - const A = pieces[i]; - const B = pieces[j]; - const rx = A.b.p.x - A.a.p.x; - const rz = A.b.p.z - A.a.p.z; - const sx = B.b.p.x - B.a.p.x; - const sz = B.b.p.z - B.a.p.z; - const den = cross(rx, rz, sx, sz); - if (Math.abs(den) < 1e-14) continue; - const qx = B.a.p.x - A.a.p.x; - const qz = B.a.p.z - A.a.p.z; - const t = cross(qx, qz, sx, sz) / den; - const v = cross(qx, qz, rx, rz) / den; - const ta = SNAP_M / Math.hypot(rx, rz); - const tb = SNAP_M / Math.hypot(sx, sz); - if (t < -ta || t > 1 + ta || v < -tb || v > 1 + tb) continue; - const innerA = t > ta && t < 1 - ta; - const innerB = v > tb && v < 1 - tb; - if (!innerA && !innerB) continue; // 끝끼리 닿음 — 같은 마디로 묶인다. - // 한쪽 끝이 다른 조각 가운데에 닿음(T) — 그 끝 자리를 가운데 쪽 조각의 마디로. - const p = - innerA && innerB - ? { x: A.a.p.x + rx * t, z: A.a.p.z + rz * t } - : innerA - ? v <= tb - ? B.a.p - : B.b.p - : t <= ta - ? A.a.p - : A.b.p; - if (innerA) A.cuts.push({ t, p }); - if (innerB) B.cuts.push({ t: v, p }); - } - } - - // 평면 그래프 — SNAP 안 마디는 하나로 묶는다(거의 겹친 마디는 각 순서를 흔들어 면 걷기를 깬다). - const nodes: P2[] = []; - const node = (p: P2): number => { - const found = nodes.findIndex((q) => Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M * 10); - if (found >= 0) return found; - nodes.push(p); - return nodes.length - 1; - }; - corners.forEach(node); - const adj: Array> = []; - const link = (i: number, j: number): void => { - if (i === j) return; - (adj[i] ??= new Set()).add(j); - (adj[j] ??= new Set()).add(i); - }; - const onEdge: Array> = [[], [], []]; - for (const piece of pieces) { - const chain = [piece.a.p, ...piece.cuts.sort((m, n) => m.t - n.t).map((c) => c.p), piece.b.p]; - const idsOf = chain.map(node); - for (let k = 1; k < idsOf.length; k += 1) link(idsOf[k - 1], idsOf[k]); - } - for (const { e, p } of edgeHits) { - const from = corners[e]; - const to = corners[(e + 1) % 3]; - const w = Math.hypot(p.x - from.x, p.z - from.z) / Math.hypot(to.x - from.x, to.z - from.z); - onEdge[e].push({ w, id: node(p) }); - } - for (let e = 0; e < 3; e += 1) { - const stops = [ - { w: 0, id: e }, - ...onEdge[e].sort((m, n) => m.w - n.w), - { w: 1, id: (e + 1) % 3 }, - ]; - for (let k = 1; k < stops.length; k += 1) link(stops[k - 1].id, stops[k].id); - } - /** 퇴화 대비 — 안쪽 선은 버리고 변 위 마디만 품은 한 면(이웃과 T자 균열은 없다). */ - const boundaryOnly = (): Array<{ contour: P2[]; holes: P2[][]; fallback: boolean }> => [ - { - contour: [0, 1, 2].flatMap((e) => [corners[e], ...onEdge[e].map((stop) => nodes[stop.id])]), - holes: [], - fallback: true, - }, - ]; - // 매달린 선(한쪽 끝이 삼각형 안에서 끝남)은 면을 안 나눈다 — 걷어 낸다. - let pruned = true; - while (pruned) { - pruned = false; - adj.forEach((set, i) => { - if (set && set.size === 1) { - const [j] = set; - adj[j].delete(i); - set.clear(); - pruned = true; - } - }); - } - // 면 걷기 — 들어온 변을 뒤집은 방향에서 시계방향 다음 변. 양(반시계) 면 = 안쪽 면. - const order: number[][] = adj.map((set, i) => - set - ? [...set].sort( - (m, n) => - Math.atan2(nodes[m].z - nodes[i].z, nodes[m].x - nodes[i].x) - - Math.atan2(nodes[n].z - nodes[i].z, nodes[n].x - nodes[i].x), - ) - : [], - ); - const seen = new Set(); - const N = nodes.length; - const faces: P2[][] = []; - const faceIds: number[][] = []; - order.forEach((list, i) => - list.forEach((j) => { - if (seen.has(i * N + j)) return; - const ring: number[] = []; - let u = i; - let v = j; - while (!seen.has(u * N + v) && ring.length <= 4 * N) { - seen.add(u * N + v); - ring.push(u); - const around = order[v]; - const k = around.indexOf(u); - const w = around[(k - 1 + around.length) % around.length]; - u = v; - v = w; - } - faces.push(ring.map((id) => nodes[id])); - faceIds.push(ring); - }), - ); - const filled: Array<{ contour: P2[]; holes: P2[][]; ids: Set }> = []; - const holes: Array<{ ring: P2[]; ids: number[] }> = []; - let total = 0; - faces.forEach((ring, f) => { - // 면 걷기 규칙상 안쪽 면은 늘 반시계(넓이 +) — 삼각형 감김과 무관. - const area = polygonArea(ring); - if (area > 1e-12) { - filled.push({ contour: ring, holes: [], ids: new Set(faceIds[f]) }); - total += area; - } else if (area < -1e-12 && !faceIds[f].includes(0)) { - holes.push({ ring, ids: faceIds[f] }); - } - }); - // 삼각형 안에 따로 떠 있는 고리(작은 구멍) — 그 고리를 품은 면의 구멍으로. - for (const hole of holes) { - const host = filled - .filter((face) => !hole.ids.some((id) => face.ids.has(id))) - .filter((face) => insidePolygon(face.contour, hole.ring[0])) - .sort((m, n) => Math.abs(polygonArea(m.contour)) - Math.abs(polygonArea(n.contour)))[0]; - if (!host) return boundaryOnly(); - host.holes.push(hole.ring); - total -= Math.abs(polygonArea(hole.ring)); - } - // 면 넓이 합이 삼각형과 다르면(퇴화) 나누지 않는다. 면이 하나여도 변 위 마디(매달린 선이 - // 이웃 삼각형을 나눈 자리)를 품으므로 그대로 낸다 — 통째로 두면 T자 균열. - if (Math.abs(total - Math.abs(triArea)) > 1e-6 * Math.max(1, Math.abs(triArea))) { - return boundaryOnly(); - } - return filled.map(({ contour, holes: inner }) => ({ contour, holes: inner })); -} - -/** 넓이 0 에 가까운 삼각형 — 가장 긴 변에서 맞은 꼭짓점까지 높이가 SNAP 안. */ -function sliver(a: P2, b: P2, c: P2): boolean { - const twice = Math.abs(cross(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z)); - const longest = Math.max( - Math.hypot(b.x - a.x, b.z - a.z), - Math.hypot(c.x - b.x, c.z - b.z), - Math.hypot(a.x - c.x, a.z - c.z), - ); - return twice < SNAP_M * longest; -} - -/** - * earcut 이 일직선 점을 걸러 내거나 넓이 0 귀로만 쓴 자리를 되살린다 — 변 위 마디가 빠지면 - * 이웃 면과 T자 균열. 넓이 0 삼각형은 버리고, 빠진 점을 변 위에 둔 삼각형을 그 점에서 둘로. - */ -function keepCollinear(ring: ReadonlyArray, tris: number[][]): number[][] { - const out = tris.filter(([a, b, c]) => !sliver(ring[a], ring[b], ring[c])).map((tri) => [...tri]); - const used = new Set(out.flat()); - ring.forEach((p, k) => { - if (used.has(k)) return; - for (let i = 0; i < out.length; i += 1) { - const tri = out[i]; - for (let e = 0; e < 3; e += 1) { - const a = ring[tri[e]]; - const b = ring[tri[(e + 1) % 3]]; - const ex = b.x - a.x; - const ez = b.z - a.z; - const length2 = ex * ex + ez * ez; - const along = (p.x - a.x) * ex + (p.z - a.z) * ez; - if (along <= 0 || along >= length2) continue; - if (Math.abs(cross(ex, ez, p.x - a.x, p.z - a.z)) / Math.sqrt(length2) > 1e-7) continue; - const c = tri[(e + 2) % 3]; - out.splice(i, 1, [tri[e], k, c], [k, tri[(e + 1) % 3], c]); - used.add(k); - return; - } - } - }); - return out; -} - -/** 보간해 이어 갈 속성 — 위치 · 법선을 뺀 나머지(`_Clip.ts` 와 같은 규칙). */ -function attributeSlots(geometry: THREE.BufferGeometry): Array<{ - name: string; - size: number; - source: THREE.BufferAttribute; - values: ArrayLike; - extra: number[]; -}> { - return Object.keys(geometry.attributes) - .filter((name) => name !== "position" && name !== "normal") - .map((name) => { - const source = geometry.getAttribute(name) as THREE.BufferAttribute; - const values = ( - source.array as unknown as { subarray(a: number, b: number): ArrayLike } - ).subarray(0, source.count * source.itemSize); - return { name, size: source.itemSize, source, values, extra: [] as number[] }; - }); -} - -/** - * 지형 한 조각을 브레이크라인으로 다시 나누고 높이를 옮긴 사본 — 칠한 정점 표식과 함께. - */ -export function retriangulateBand( - geometry: THREE.BufferGeometry, - ctx: BandContext, - stats: BandStats, -): { geometry: THREE.BufferGeometry; keep: Uint8Array } { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const base = position.count; - const px = (position.array as Float32Array).subarray(0, base * 3); - const positions = Float32Array.from(px); - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : base / 3; - const slots = attributeSlots(geometry); - const color = slots.find((slot) => slot.name === "color"); - const scale = color?.source.normalized - ? color.source.array instanceof Uint8Array - ? 255 - : 65535 - : 1; - const grid = new SegmentGrid(ctx.lines); - const kept: number[] = []; - const extra: number[] = []; - const zones: number[] = []; - /** 원 정점 — 0 아직 · 1 원지반 · 2 성토 · 3 절토(+1). */ - const done = new Uint8Array(base); - - const count = (delta: number): void => { - if (Math.abs(delta) < MOVE_MIN_M) return; - stats.moved += 1; - if (delta > 0) { - stats.raised += 1; - stats.maxRaiseM = Math.max(stats.maxRaiseM, delta); - } else { - stats.lowered += 1; - stats.maxCutM = Math.max(stats.maxCutM, -delta); - } - }; - const settle = (v: number): void => { - if (done[v]) return; - const y = px[v * 3 + 1]; - const out = ctx.deform(px[v * 3], y, px[v * 3 + 2]); - positions[v * 3 + 1] = out.y; - done[v] = out.zone + 1; - count(out.y - y); - }; - - for (let t = 0; t < triCount; t += 1) { - const ids: [number, number, number] = [0, 1, 2].map((k) => - index ? index.getX(t * 3 + k) : t * 3 + k, - ) as [number, number, number]; - const corners = ids.map((v) => ({ x: px[v * 3], z: px[v * 3 + 2] })) as [P2, P2, P2]; - const centroid = { - x: (corners[0].x + corners[1].x + corners[2].x) / 3, - z: (corners[0].z + corners[1].z + corners[2].z) / 3, - }; - if (!corners.some(ctx.touched) && !ctx.touched(centroid)) { - kept.push(...ids); - continue; - } - const near = grid.near( - Math.min(corners[0].x, corners[1].x, corners[2].x), - Math.max(corners[0].x, corners[1].x, corners[2].x), - Math.min(corners[0].z, corners[1].z, corners[2].z), - Math.max(corners[0].z, corners[1].z, corners[2].z), - ); - const faces = near.length ? splitTriangle(corners, ids, near, grid.segs) : null; - if (faces?.some((face) => face.fallback)) stats.fallback += 1; - if (!faces) { - if (ctx.removed(centroid)) continue; - ids.forEach(settle); - kept.push(...ids); - continue; - } - stats.split += 1; - // 원 삼각형 평면 · 속성 무게중심 보간. - const [a, b, c] = corners; - const den = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z); - const weights = (p: P2): [number, number, number] => { - const w0 = ((b.z - c.z) * (p.x - c.x) + (c.x - b.x) * (p.z - c.z)) / den; - const w1 = ((c.z - a.z) * (p.x - c.x) + (a.x - c.x) * (p.z - c.z)) / den; - return [w0, w1, 1 - w0 - w1]; - }; - const sign = Math.sign(den); - for (const face of faces) { - const ring = [...face.contour, ...face.holes.flat()]; - const tris = keepCollinear( - ring, - THREE.ShapeUtils.triangulateShape( - face.contour.map((p) => new THREE.Vector2(p.x, p.z)), - face.holes.map((hole) => hole.map((p) => new THREE.Vector2(p.x, p.z))), - ), - ); - for (const tri of tris) { - const pts = tri.map((k) => ring[k]); - const mid = { - x: (pts[0].x + pts[1].x + pts[2].x) / 3, - z: (pts[0].z + pts[1].z + pts[2].z) / 3, - }; - if (ctx.removed(mid)) continue; - const w = weights(mid); - const zone = ctx.deform( - mid.x, - w[0] * px[ids[0] * 3 + 1] + w[1] * px[ids[1] * 3 + 1] + w[2] * px[ids[2] * 3 + 1], - mid.z, - ).zone; - const turn = cross( - pts[1].x - pts[0].x, - pts[1].z - pts[0].z, - pts[2].x - pts[0].x, - pts[2].z - pts[0].z, - ); - // 원 삼각형과 같은 감김(앞면 그대로). - const ordered = Math.sign(turn) === sign ? pts : [pts[0], pts[2], pts[1]]; - for (const p of ordered) { - const pw = weights(p); - const y = - pw[0] * px[ids[0] * 3 + 1] + pw[1] * px[ids[1] * 3 + 1] + pw[2] * px[ids[2] * 3 + 1]; - const out = ctx.deform(p.x, y, p.z); - count(out.y - y); - kept.push(base + extra.length / 3); - extra.push(p.x, out.y, p.z); - zones.push(zone); - for (const slot of slots) { - for (let k = 0; k < slot.size; k += 1) { - slot.extra.push( - pw[0] * slot.values[ids[0] * slot.size + k] + - pw[1] * slot.values[ids[1] * slot.size + k] + - pw[2] * slot.values[ids[2] * slot.size + k], - ); - } - } - } - } - } - } - - const out = new THREE.BufferGeometry(); - const all = new Float32Array(base * 3 + extra.length); - all.set(positions, 0); - all.set(extra, base * 3); - out.setAttribute("position", new THREE.BufferAttribute(all, 3)); - const keep = new Uint8Array(base + zones.length); - const paint = (values: { [i: number]: number }, v: number, zone: number): void => { - if (!color || zone < 1) return; - const rgb = ctx.tint(zone as 1 | 2); - for (let k = 0; k < 3; k += 1) - values[v * color.size + k] = scale === 1 ? rgb[k] : Math.round(rgb[k] * scale); - keep[v] = 1; - }; - for (const slot of slots) { - const ctor = slot.source.array.constructor as new (length: number) => THREE.TypedArray; - const merged = new ctor(base * slot.size + slot.extra.length); - merged.set(slot.values as ArrayLike, 0); - merged.set( - slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra, - base * slot.size, - ); - if (slot === color) { - for (let v = 0; v < base; v += 1) paint(merged, v, done[v] - 1); - zones.forEach((zone, i) => paint(merged, base + i, zone)); - } - out.setAttribute( - slot.name, - new THREE.BufferAttribute(merged, slot.size, slot.source.normalized), - ); - } - out.setIndex(kept); - out.computeBoundingSphere(); - out.computeBoundingBox(); - return { geometry: out, keep }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts deleted file mode 100644 index 240e465d1..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Structure.ts +++ /dev/null @@ -1,430 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Structure.ts - * 새 방식 지형 변형의 **구조물 마스크**(계획서 115-2) — 순수 기하. - * - * · 구조물 구간 단면 = 소유 측점 실루엣(`_Station_Structure.ts`)의 상부 포락선. - * 구간 안은 고정, 구간 끝 → 이웃 측점까지 제어점 테이퍼(`_Frames.ts`). - * · 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(`_Plan_Footprint.ts`). 솔리드가 메운다. - * · 성토 금지 고리 = 날개 안쪽 유로 · 에이프런 — 원지반(세월교는 바닥판 상단) 위로 안 올린다. - * - * 구조물 솔리드(`_Structures*.ts`)는 읽기만 한다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { - classifyStation, - PIECE_COLS, - pieceKey, - resample, - type OffsetPoint, - type StationPieces, -} from "./B05_Profile_UI_Corridor_Station"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { - structureFootprintLoops, - type StationAxis, -} from "./B05_Profile_UI_Corridor_Plan_Footprint"; -import type { - DeformKind, - DeformLimit, - DeformSide, - StructureSlope, -} from "./B05_Profile_UI_Corridor_Deform"; - -/** - * 상부 포락선 — 바깥거리(`offset_m` = s ≥ 0)마다 실루엣 마디 중 가장 높은 표고. - * 벽 앞면(거의 수직) · 다단 계단처럼 바깥으로 갔다 돌아오는 줄을 높이장 한 줄로 편다. - */ -export function upperEnvelope(points: ReadonlyArray): OffsetPoint[] { - if (points.length < 2) return points.map((point) => ({ ...point })); - const xs = [...new Set(points.map((point) => point.offset_m))].sort((a, b) => a - b); - return xs.map((x) => { - let best = -Infinity; - for (let i = 0; i < points.length - 1; i += 1) { - const a = points[i]; - const b = points[i + 1]; - const lo = Math.min(a.offset_m, b.offset_m); - const hi = Math.max(a.offset_m, b.offset_m); - if (x < lo - 1e-9 || x > hi + 1e-9) continue; - const z = - hi - lo < 1e-9 - ? Math.max(a.elevation_m, b.elevation_m) - : a.elevation_m + - ((b.elevation_m - a.elevation_m) * (x - a.offset_m)) / (b.offset_m - a.offset_m); - if (z > best) best = z; - } - return { offset_m: x, elevation_m: best }; - }); -} - -/** 측점의 노면부 바깥 끝(측구 > 노견) — 행 앵커와 같은 자리. */ -function anchorOf(station: StationPieces, side: DeformSide): OffsetPoint | null { - const piece = - station.pieces.get(pieceKey("ditch", side)) ?? station.pieces.get(pieceKey("shoulder", side)); - if (!piece || piece.length < 2) return null; - return side === "left" ? piece[piece.length - 1] : piece[0]; -} - -/** - * 실루엣 → 앵커 기준 상대 단면(측점 단면 `slopeRelative` 와 같은 꼴 · 같은 열 수). - * 앵커 안쪽(노면부)은 잘라 내고, 앵커 자리 교차점을 첫 점으로 세운다. - */ -function silhouetteSlope( - points: ReadonlyArray, - side: DeformSide, - anchor: OffsetPoint, - kind: DeformKind, -): OffsetPoint[] | null { - const sign = side === "left" ? 1 : -1; - const outward = points.map((point) => ({ - offset_m: (point.offset_m - anchor.offset_m) * sign, - elevation_m: point.elevation_m - anchor.elevation_m, - })); - const kept: OffsetPoint[] = []; - outward.forEach((point, i) => { - if (point.offset_m < 0) return; - const previous = outward[i - 1]; - if (!kept.length && previous && previous.offset_m < 0) { - const t = -previous.offset_m / (point.offset_m - previous.offset_m); - kept.push({ - offset_m: 0, - elevation_m: previous.elevation_m + (point.elevation_m - previous.elevation_m) * t, - }); - } - kept.push(point); - }); - if (kept.length && kept[0].offset_m > 1e-9) kept.unshift({ offset_m: 0, elevation_m: 0 }); - const envelope = upperEnvelope(kept); - if (envelope.length < 2 || envelope[envelope.length - 1].offset_m < 1e-3) return null; - const sampled = resample(envelope, PIECE_COLS[kind]); - // 측점 단면 꼴로 — 좌측은 바깥거리 그대로, 우측은 부호를 뒤집어 오름차순(도로측 원점이 끝). - return side === "left" - ? sampled - : sampled - .map((point) => ({ offset_m: -point.offset_m, elevation_m: point.elevation_m })) - .reverse(); -} - -/** 구조물 측점마다 그 측 실루엣을 구간 단면으로 — `stationProfileSource` 두 번째 인자. */ -export function structureSlopes(sections: ReadonlyArray): StructureSlope[] { - const slopes: StructureSlope[] = []; - for (const section of sections) { - const silhouettes = structureSilhouettes(section); - if (!silhouettes.length) continue; - const station = classifyStation(section); - const mode = section.design?.section_mode; - if (!station || !mode) continue; - for (const silhouette of silhouettes) { - const side = silhouette.side; - const anchor = anchorOf(station, side); - if (!anchor || !silhouette.points.length) continue; - // auto 는 기울기로 — 바깥 끝이 앵커보다 낮으면 성토(115-5 P0-1 ③). 단면 모드로 가르면 - // both_cut 유출측 내려가는 선이 절토가 돼 구덩이를 팠다. - const sign = side === "left" ? 1 : -1; - const outer = silhouette.points.reduce((best, point) => - (point.offset_m - best.offset_m) * sign > 0 ? point : best, - ); - const kind: DeformKind = - silhouette.kind === "auto" - ? outer.elevation_m < anchor.elevation_m - ? "fill" - : "cut" - : silhouette.kind; - const points = silhouetteSlope(silhouette.points, side, anchor, kind); - if (!points) continue; - slopes.push({ - chainage_m: section.chainage_m, - side, - kind, - span: silhouette.span, - points, - endsAtStructure: silhouette.endsAtStructure === true, - }); - } - } - return slopes; -} - -/** 측점 프레임 축. */ -function axisOf(section: CrossSection): StationAxis { - return { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - }; -} - -/** 그 측점에 선 구조물 한 벌. */ -function setOf( - section: CrossSection, - structures: ReadonlyArray, -): CorridorStructure[] { - return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6); -} - -/** 윗면으로 볼 높이 여유(m) — 링 폴리곤 최고점에서 이만큼 안. */ -const TOP_TOLERANCE_M = 0.05; -/** 구조물 둘레 드레이프 띠(m) — 풋프린트 밖 이만큼까지 지형을 윗면 아래로(115-5 P1-4). */ -export const DRAPE_BAND_M = 0.75; -/** 관 입구 구멍 — 끝에서 안쪽으로 볼 길이 · 걸음(m). */ -const MOUTH_REACH_M = 3; -const MOUTH_STEP_M = 0.1; - -/** 링 폴리곤의 윗면 점만 — 기초 · 뒷굽은 빼고 솔리드 윗면 외곽만 구멍으로(115-5 P1-4). */ -function topOnly(polygon: ReadonlyArray<[number, number]>): Array<[number, number]> { - const top = Math.max(...polygon.map((point) => point[1])); - return polygon.filter((point) => point[1] >= top - TOP_TOLERANCE_M); -} - -function topStructure(structure: CorridorStructure): CorridorStructure { - return { - ...structure, - polygon: structure.polygon && topOnly(structure.polygon), - polygons: structure.polygons?.map(topOnly), - }; -} - -/** - * 지형 구멍 고리(모델 좌표) — 구조물 **윗면** 외곽 · BOX 아령 외곽. 도로부 안쪽은 뺀다. - * 기초 · 뒷굽은 지형이 묻는다(둘레 드레이프 `structureTopAt`). - */ -export function structureHoleLoops( - sections: ReadonlyArray, - structures: ReadonlyArray, -): Array> { - const loops: Array> = []; - for (const section of sections) { - const set = setOf(section, structures).map(topStructure); - const edges = section.design?.road_edges; - if (!set.length || !edges) continue; - (["left", "right"] as const).forEach((side) => { - const roadEdge = side === "left" ? edges.left.offset_m : edges.right.offset_m; - structureFootprintLoops(set, side, roadEdge, 0, axisOf(section)).forEach((loop) => - loops.push(loop.map(([x, y]) => [x, y] as [number, number])), - ); - }); - } - return loops; -} - -/** 볼록 껍질(단조 사슬) — 날개 두 장의 링 중심을 감싸 날개 안쪽 유로 자리를 낸다. */ -function convexHull(points: Array<[number, number]>): Array<[number, number]> { - const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); - if (sorted.length < 3) return sorted; - const cross = (o: [number, number], a: [number, number], b: [number, number]): number => - (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - const lower: Array<[number, number]> = []; - for (const point of sorted) { - while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) - lower.pop(); - lower.push(point); - } - const upper: Array<[number, number]> = []; - for (let i = sorted.length - 1; i >= 0; i -= 1) { - const point = sorted[i]; - while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) - upper.pop(); - upper.push(point); - } - return [...lower.slice(0, -1), ...upper.slice(0, -1)]; -} - -function polygonArea(loop: ReadonlyArray<[number, number]>): number { - let area = 0; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - area += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1]; - } - return Math.abs(area) / 2; -} - -/** - * 성토 금지 고리 — 측마다 날개(날개벽 · 날개 에이프런) 링 중심을 감싼 볼록 껍질. - * 천장 = 세월교 바닥판 상단(그 측 실루엣) · BOX 는 원지반. - */ -export function noFillLimits( - sections: ReadonlyArray, - structures: ReadonlyArray, -): DeformLimit[] { - const limits: DeformLimit[] = []; - for (const section of sections) { - const set = setOf(section, structures); - if (!set.length) continue; - const axis = axisOf(section); - const floors = new Map(); - structureSilhouettes(section).forEach((silhouette) => { - if (silhouette.floorTopZ !== undefined) floors.set(silhouette.side, silhouette.floorTopZ); - }); - (["left", "right"] as const).forEach((side) => { - const centers: Array<[number, number]> = []; - for (const structure of set) { - const rings = structure.rings; - if (!structure.wing || structure.kind === "pipe" || !rings || rings.length < 2) continue; - const middle = rings[Math.floor(rings.length / 2)]; - const offset = (middle.cx - axis.cx) * axis.leftX + (middle.cy - axis.cy) * axis.leftY; - if ((offset >= 0 ? "left" : "right") !== side) continue; - rings.forEach((ring) => centers.push([ring.cx, ring.cy])); - } - const loop = convexHull(centers); - if (loop.length < 3 || polygonArea(loop) < 0.01) return; - limits.push({ loop, ceiling: floors.get(side) ?? null }); - }); - } - return limits; -} - -/** 한 구조물의 링 사이 마디 — 둘레 드레이프 조회용. */ -interface DrapeSpan { - a: StructureFrame; - b: StructureFrame; - /** 링마다 [편거리 lo, hi, 윗면 표고(dz 포함)]. */ - wa: [number, number, number]; - wb: [number, number, number]; - /** 끝 마디 밖으로 늘릴 비율(띠 / 링 간격). */ - before: number; - after: number; - box: [number, number, number, number]; -} - -/** - * 구조물 둘레 드레이프(115-5 P1-4) — 풋프린트 안 · 둘레 띠 자리의 솔리드 윗면 표고(모델 - * 좌표). 지형은 `min(z', 윗면)` — 절토 어깨 · 성토가 벽 상단에서 멈춘다. 없으면 null. - * 겹친 부재는 가장 높은 윗면. 솔리드는 읽기만 한다. - */ -export function structureTopAt( - structures: ReadonlyArray, -): (x: number, y: number) => number | null { - const spans: DrapeSpan[] = []; - for (const structure of structures) { - const rings = structure.rings; - if (structure.kind === "pipe" || !rings || rings.length < 2) continue; - const window = (index: number): [number, number, number] | null => { - const polygon = structure.polygons?.[index] ?? structure.polygon; - if (!polygon?.length) return null; - const offsets = polygon.map((point) => point[0]); - const top = Math.max(...polygon.map((point) => point[1])); - return [Math.min(...offsets), Math.max(...offsets), top + rings[index].dz]; - }; - for (let i = 0; i < rings.length - 1; i += 1) { - const wa = window(i); - const wb = window(i + 1); - if (!wa || !wb) continue; - const a = rings[i]; - const b = rings[i + 1]; - const gap = Math.hypot(b.cx - a.cx, b.cy - a.cy); - if (gap < 1e-6) continue; - const corners = [a, b].flatMap((frame, k) => { - const w = k ? wb : wa; - return [w[0] - DRAPE_BAND_M, w[1] + DRAPE_BAND_M].map((o) => [ - frame.cx + frame.leftX * o, - frame.cy + frame.leftY * o, - ]); - }); - const xs = corners.map((p) => p[0]); - const ys = corners.map((p) => p[1]); - spans.push({ - a, - b, - wa, - wb, - before: i === 0 ? DRAPE_BAND_M / gap : 0, - after: i === rings.length - 2 ? DRAPE_BAND_M / gap : 0, - box: [ - Math.min(...xs) - DRAPE_BAND_M, - Math.max(...xs) + DRAPE_BAND_M, - Math.min(...ys) - DRAPE_BAND_M, - Math.max(...ys) + DRAPE_BAND_M, - ], - }); - } - } - // 칸 색인 — 정점마다 마디 전부를 훑지 않는다. - const CELL = 4; - const cells = new Map(); - for (const span of spans) { - const [x0, x1, y0, y1] = span.box; - for (let c = Math.floor(x0 / CELL); c <= Math.floor(x1 / CELL); c += 1) { - for (let g = Math.floor(y0 / CELL); g <= Math.floor(y1 / CELL); g += 1) { - const key = `${c},${g}`; - const list = cells.get(key); - if (list) list.push(span); - else cells.set(key, [span]); - } - } - } - return (x, y) => { - let best: number | null = null; - for (const span of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) { - const [x0, x1, y0, y1] = span.box; - if (x < x0 || x > x1 || y < y0 || y > y1) continue; - const { a, b, wa, wb } = span; - const along = (f: StructureFrame): number => (x - f.cx) * f.leftY - (y - f.cy) * f.leftX; - const a0 = along(a); - const a1 = along(b); - if (Math.abs(a0 - a1) < 1e-12) continue; - const t = a0 / (a0 - a1); - if (t < -span.before || t > 1 + span.after) continue; - const k = Math.min(1, Math.max(0, t)); - const oa = (x - a.cx) * a.leftX + (y - a.cy) * a.leftY; - const ob = (x - b.cx) * b.leftX + (y - b.cy) * b.leftY; - const o = oa + (ob - oa) * k; - const lo = wa[0] + (wb[0] - wa[0]) * k - DRAPE_BAND_M; - const hi = wa[1] + (wb[1] - wa[1]) * k + DRAPE_BAND_M; - if (o < lo || o > hi) continue; - const top = wa[2] + (wb[2] - wa[2]) * k; - if (best === null || top > best) best = top; - } - return best; - }; -} - -/** - * 관 입구 구멍(115-5 P1-4) — 관 끝에서 안쪽으로 관 머리가 변형 지형 위로 드러난 만큼의 - * 관경 폭 사각 고리(모델 좌표). 묻힌 끝은 구멍 없음. - */ -export function pipeMouthLoops( - structures: ReadonlyArray, - groundAfter: (x: number, y: number) => number | null, -): Array> { - const loops: Array> = []; - for (const structure of structures) { - const { pipe, frame } = structure; - if (structure.kind !== "pipe" || !pipe || !frame) continue; - const length = Math.abs(pipe.end[0] - pipe.start[0]); - if (length < 0.2) continue; - const half = pipe.diameterM / 2; - const plan = (o: number, lateral: number): [number, number] => [ - frame.cx + frame.leftX * o + frame.leftY * lateral, - frame.cy + frame.leftY * o - frame.leftX * lateral, - ]; - const crownAt = (o: number): number => { - const t = (o - pipe.start[0]) / (pipe.end[0] - pipe.start[0]); - return pipe.start[1] + (pipe.end[1] - pipe.start[1]) * t + pipe.diameterM; - }; - for (const [from, to] of [ - [pipe.start[0], pipe.end[0]], - [pipe.end[0], pipe.start[0]], - ]) { - const dir = Math.sign(to - from); - let reach = 0; - for (let d = 0; d <= Math.min(MOUTH_REACH_M, length / 2) + 1e-9; d += MOUTH_STEP_M) { - const o = from + dir * d; - const [x, y] = plan(o, 0); - const ground = groundAfter(x, y); - if (ground === null || crownAt(o) <= ground) break; - reach = d; - } - if (reach < MOUTH_STEP_M) continue; - const inner = from + dir * reach; - loops.push([ - plan(from, -half), - plan(inner, -half), - plan(inner, half), - plan(from, half), - plan(from, -half), - ]); - } - } - return loops; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts deleted file mode 100644 index 4ff87d5d8..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Deform_Terrain.ts +++ /dev/null @@ -1,317 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Deform_Terrain.ts - * 새 방식 지형 — 밴드 사본에 브레이크라인(노면 끝 · 비탈 끝 · 단면 꺾임 · 구조물 윗면 - * 외곽 · 관 입구)을 끼워 다시 삼각분할하고(`_Deform_Breakline.ts`) 사본 정점의 높이만 - * 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1 · 115-5). - * - * 원본 · 분할본 정점 버퍼는 건드리지 않는다 — 새 위치 배열을 만든다. [예상형상] OFF 면 - * 뷰어가 원본을 그대로 보인다. 법선은 새로 만들지 않는다(`_Clip.ts`). - * 색 = 자리(비탈 끝 안 성토 · 절토) — 면 단위 · 경계 = 비탈 끝 선 · 빗금 없음. - * 구조물 둘레는 솔리드 윗면 아래로 드레이프(115-5 P1-4). 손대는 자리는 행별 비탈 끝 + 5 m - * 띠(P2-5) — 그 밖 지형은 1 mm 도 안 움직인다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { CellRegion, scenePlanMapper, type P2 } from "./B05_Profile_UI_Corridor_Region"; -import { - buildDeformField, - stationProfileSource, - type DeformField, - type DeformRow, -} from "./B05_Profile_UI_Corridor_Deform"; -import { - noFillLimits, - pipeMouthLoops, - structureHoleLoops, - structureSlopes, - structureTopAt, -} from "./B05_Profile_UI_Corridor_Deform_Structure"; -import { - retriangulateBand, - type BandStats, - type Breakline, -} from "./B05_Profile_UI_Corridor_Deform_Breakline"; - -/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */ -const CUT_COLOR = new THREE.Color(0xb5179e); -const FILL_COLOR = new THREE.Color(0x06b6d4); -/** 손대는 띠 — 비탈 끝 밖 여유(m) · 칸(m). */ -const BAND_PAD_M = 5; -const MASK_CELL_M = 2; -/** 비탈 끝 · 꺾임 선으로 세울 최소 거리(m) — 앵커 · 비탈 끝에 겹친 선은 세우지 않는다. */ -const LINE_MIN_M = 0.05; - -/** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */ -class RoadStrip extends CellRegion { - constructor(rows: ReadonlyArray, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - const left = rows.map((row) => toScene(row.left[0], row.left[1])); - const right = rows.map((row) => toScene(row.right[0], row.right[1])); - for (let i = 0; i < rows.length - 1; i += 1) { - this.addCell(left[i], right[i], right[i + 1], left[i + 1]); - this.addSegment(left[i], left[i + 1]); - this.addSegment(right[i], right[i + 1]); - } - this.addSegment(left[0], right[0]); - this.addSegment(left[rows.length - 1], right[rows.length - 1]); - this.buildIndex(); - } -} - -/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */ -export interface DeformStats extends BandStats { - rows: number; - holes: number; - lines: number; -} - -/** 브레이크라인 — 노면 끝(앵커 · 마구리) · 행별 비탈 끝 · 단면 꺾임(씬 평면). */ -export function deformBreaklines( - field: DeformField, - toScene: (x: number, y: number) => P2, -): Breakline[] { - const rows = field.rows; - const n = rows.length; - const lines: Breakline[] = [ - rows.map((row) => toScene(row.left[0], row.left[1])), - rows.map((row) => toScene(row.right[0], row.right[1])), - [toScene(rows[0].left[0], rows[0].left[1]), toScene(rows[0].right[0], rows[0].right[1])], - [ - toScene(rows[n - 1].left[0], rows[n - 1].left[1]), - toScene(rows[n - 1].right[0], rows[n - 1].right[1]), - ], - ]; - const at = (r: number, si: number, s: number): P2 => toScene(...field.pointAt(r, si, s)); - for (let si = 0; si < 2; si += 1) { - for (let ki = 0; ki < 2; ki += 1) { - let run: P2[] = []; - const runs = new Map(); - const flush = (line: P2[]): void => { - if (line.length >= 2) lines.push(line); - }; - let start = -1; - for (let r = 0; r < n; r += 1) { - const reach = field.catchAt(r, si, ki); - if (reach > LINE_MIN_M) { - if (!run.length) start = r; - run.push(at(r, si, reach)); - } - // 비탈이 시작 · 끝나는 행 — 앵커 → 비탈 끝 마구리 선(색 경계 · 끝 단차). - const ends = run.length && (reach <= LINE_MIN_M || r === n - 1); - if (ends) { - const last = reach > LINE_MIN_M ? r : r - 1; - for (const k of new Set([start, last])) { - lines.push([at(k, si, 0), at(k, si, field.catchAt(k, si, ki))]); - } - } - if (reach <= LINE_MIN_M) { - flush(run); - run = []; - } - const cols = new Set(); - for (const [col, s] of field.bendsAt(r, si, ki)) { - if (s < LINE_MIN_M || s > reach - LINE_MIN_M) continue; - cols.add(col); - const line = runs.get(col) ?? []; - line.push(at(r, si, s)); - runs.set(col, line); - } - runs.forEach((line, col) => { - if (cols.has(col)) return; - flush(line); - runs.delete(col); - }); - } - flush(run); - runs.forEach(flush); - } - } - return lines; -} - -/** 손대는 자리 — 행마다 노면부 + 좌우 비탈 끝 + 5 m 띠, 구멍 상자 둘레(P2-5). */ -function bandMask( - field: DeformField, - toScene: (x: number, y: number) => P2, - holes: ReadonlyArray, -): (p: P2) => boolean { - const marks: P2[] = []; - field.rows.forEach((row, r) => { - const width = Math.hypot(row.left[0] - row.right[0], row.left[1] - row.right[1]); - const reach = (si: number): number => - Math.max(field.catchAt(r, si, 0), field.catchAt(r, si, 1)) + BAND_PAD_M; - for (let s = -width; s <= reach(0); s += 1) marks.push(toScene(...field.pointAt(r, 0, s))); - for (let s = 0; s <= reach(1); s += 1) marks.push(toScene(...field.pointAt(r, 1, s))); - }); - holes.forEach((loop) => { - const xs = loop.map((p) => p.x); - const zs = loop.map((p) => p.z); - for (let x = Math.min(...xs) - 1.5; x <= Math.max(...xs) + 1.5; x += 1) { - for (let z = Math.min(...zs) - 1.5; z <= Math.max(...zs) + 1.5; z += 1) marks.push({ x, z }); - } - }); - if (!marks.length) return () => false; - let minX = Infinity; - let minZ = Infinity; - let maxX = -Infinity; - let maxZ = -Infinity; - for (const p of marks) { - minX = Math.min(minX, p.x); - minZ = Math.min(minZ, p.z); - maxX = Math.max(maxX, p.x); - maxZ = Math.max(maxZ, p.z); - } - minX -= MASK_CELL_M * 2; - minZ -= MASK_CELL_M * 2; - const cols = Math.ceil((maxX - minX) / MASK_CELL_M) + 3; - const lines = Math.ceil((maxZ - minZ) / MASK_CELL_M) + 3; - const mask = new Uint8Array(cols * lines); - for (const p of marks) { - const c = Math.floor((p.x - minX) / MASK_CELL_M); - const g = Math.floor((p.z - minZ) / MASK_CELL_M); - for (let dg = -1; dg <= 1; dg += 1) { - for (let dc = -1; dc <= 1; dc += 1) mask[(g + dg) * cols + c + dc] = 1; - } - } - return (p) => { - const c = Math.floor((p.x - minX) / MASK_CELL_M); - const g = Math.floor((p.z - minZ) / MASK_CELL_M); - return c >= 0 && g >= 0 && c < cols && g < lines && mask[g * cols + c] === 1; - }; -} - -function insideLoop(loop: ReadonlyArray, p: P2): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const a = loop[i]; - const b = loop[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) - hit = !hit; - } - return hit; -} - -/** - * 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 다시 나눠 변형한다. 변형 장을 못 세우면 - * (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다. - */ -export function deformTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, - heightIndex: TerrainHeightIndex, -): { root: THREE.Object3D; stats: DeformStats } | null { - const sections = build.sections; - if (!sections?.length) return null; - const structures = build.structures ?? []; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const groundAt = (x: number, y: number): number | null => { - const height = heightIndex.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; - // 구조물 구간 = 실루엣 상부 포락선 + 이웃 측점 테이퍼 · 날개 안쪽 성토 금지(115-2). - const source = stationProfileSource(sections, structureSlopes(sections)); - const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures)); - if (!field) return null; - const toScene = scenePlanMapper(bounds); - // 지형 구멍 = 노면부 띠 + 구조물 윗면 외곽 · BOX 아령 외곽 + 관 입구 — 솔리드가 메운다. - const road = new RoadStrip(field.rows, bounds); - const groundAfter = (x: number, y: number): number | null => { - const ground = groundAt(x, y); - return ground === null ? null : field.deformZ(x, y, ground); - }; - const holes = [ - ...structureHoleLoops(sections, structures), - ...pipeMouthLoops(structures, groundAfter), - ] - .map((loop) => loop.map(([x, y]) => toScene(x, y))) - .filter((loop) => loop.length >= 3); - const closed = holes.map((loop) => { - const [first] = loop; - const last = loop[loop.length - 1]; - return first.x === last.x && first.z === last.z ? loop : [...loop, first]; - }); - const lines = [...deformBreaklines(field, toScene), ...closed]; - const boxes = holes.map((loop) => { - const xs = loop.map((p) => p.x); - const zs = loop.map((p) => p.z); - return [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]; - }); - const topAt = structureTopAt(structures); - const stats: DeformStats = { - rows: field.rows.length, - holes: holes.length, - lines: lines.length, - split: 0, - fallback: 0, - moved: 0, - raised: 0, - lowered: 0, - maxRaiseM: 0, - maxCutM: 0, - }; - const context = { - lines, - removed: (p: P2): boolean => - road.contains(p) || - holes.some( - (loop, i) => - p.x >= boxes[i][0] && - p.x <= boxes[i][1] && - p.z >= boxes[i][2] && - p.z <= boxes[i][3] && - insideLoop(loop, p), - ), - touched: bandMask(field, toScene, holes), - deform: (x: number, y: number, z: number) => { - const mx = x + ox; - const my = -z + oy; - const at = field.deformAt(mx, my, y + oz); - const top = topAt(mx, my); - const out = top !== null && at.z > top ? top : at.z; - return { y: out - oz, zone: at.zone }; - }, - tint: (zone: 1 | 2): [number, number, number] => { - const color = zone === 1 ? FILL_COLOR : CUT_COLOR; - return [color.r, color.g, color.b]; - }, - }; - const root = new THREE.Group(); - root.name = "terrain-deformed"; - split.near.forEach((part) => { - if (part.points) { - // 포인트클라우드는 변형 대상이 아니다 — 분할본 그대로 얹는다. - const points = new THREE.Points(part.geometry, part.material as THREE.Material); - points.userData.owned = false; - root.add(points); - return; - } - const { geometry, keep } = retriangulateBand(part.geometry, context, stats); - const normal = part.geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - if (normal) { - // 법선은 원본 것만 물려받는다 — 새 정점은 위 방향(`_Clip.ts` 와 같은 규칙). - const count = geometry.getAttribute("position").count; - const normals = new Float32Array(count * 3); - normals.set((normal.array as Float32Array).subarray(0, normal.count * 3), 0); - for (let v = normal.count; v < count; v += 1) normals[v * 3 + 1] = 1; - geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); - } - const mesh = new THREE.Mesh(geometry, part.material); - mesh.userData.owned = true; - mesh.userData.keepColor = keep; - root.add(mesh); - }); - split.far.forEach((part) => { - const mesh = new THREE.Mesh(part.geometry, part.material); - mesh.userData.owned = false; - root.add(mesh); - }); - return { root, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts deleted file mode 100644 index 960302b92..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Envelope.ts +++ /dev/null @@ -1,331 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Envelope.ts - * 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.** - * - * 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자 - * 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고 - * 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도 - * 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역). - * - * 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변). - * ========================================================================== */ - -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types"; -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures"; - -/** - * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. - * - * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. - * - * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. - * 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27. - * 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자). - * 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가 - * 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02). - */ -export const ENVELOPE_VERSION = 6; - -export interface CorridorEnvelope { - version: number; - hash: string; - ribbons: Array<{ - kind: CorridorRibbon["kind"]; - side: CorridorRibbon["side"]; - colCount: number; - chainages: number[]; - positionsBase64: string; - /** - * 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도 - * 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연 - * 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다. - */ - patch?: boolean; - patchClip?: boolean; - /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ - cellMaskBase64?: string; - /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ - trimTrisBase64?: string; - trimUvsBase64?: string; - }>; - outline: CorridorBuildResult["outline"]; - /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ - caps?: CorridorBuildResult["caps"]; - /** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */ - structures?: CorridorBuildResult["structures"]; - /** - * 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면 - * 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다. - */ - planCurves?: CorridorBuildResult["planCurves"]; - /** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */ - cutWalls?: Array<{ - side: "left" | "right"; - positionsBase64: string; - }>; -} - -/** 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"); -} - -/** - * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** - * - * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 - * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 - * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 - * 섞어 두면 배포와 동시에 저장본이 만료된다. - */ -// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· -// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). -// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 -// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 -// 아니라 리본이 끝난 자리다(2026-08-27 사용자). -// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) + -// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정), -// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토) -// (2026-08-27 사용자). -// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다 -// (2026-08-27 사용자). -// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 -// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). -// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) -// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 -// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 -// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 -// 성토면이 있어야 한다 — 삭제가 아니다). -// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 -// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 -// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). -// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 -// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), -// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, -// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. -// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 -// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). -// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 -// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 -// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: -// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). -// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, -// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 -// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 -// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). -// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 -// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) -// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 -// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). -// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에 -// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.) -// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). -// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 -// (2026-08-27 사용자). 안쪽은 안 자른다. -// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 -// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 -// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 -// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). -// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, -// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 -// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 -// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). -// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 -// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — -// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). -// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). -// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열 -// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는 -// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다** -// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선 -// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다. -// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥 -// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이 -// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해 -// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히 -// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이 -// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다"). -// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로 -// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지 -// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m). -// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취 -// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다 -// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출"). -// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용). -// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로 -// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선 -// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고 -// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음 -// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이 -// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가 -// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은 -// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다. -// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로 -// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을 -// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다 -// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로 -// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다 -// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까"). -// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어 -// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**. -// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다 -// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. -// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 -// 가르고 **닫힌 고리만** 담는다. -// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 -// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). -// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 -// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). -// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2, -// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계). -// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와 -// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자). -// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만 -// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치 -// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취 -// 영역을 내므로 같은 반대 규칙이 성립한다. -// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가 -// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆 -// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 — -// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다. -// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를 -// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길 -// 부재를 못 정하므로 만료한다. -// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로 -// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다. -// 93 = 측점 사이 행 자리를 **노선 길이 직선 보간**으로(115-5 P0-2) — 균일 Catmull-Rom 이 -// 행을 최대 7.2 m 미끄러뜨리고 되접었다. 모든 행 자리가 바뀌어 저장본을 만료한다. -const BUILD_VERSION = 93; - -/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ -export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { - const parts: Array = [`v${BUILD_VERSION}`, 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)), - ); - // 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다. - structureHashParts(section).forEach((part) => parts.push(part)); - }); - return fnv1a(parts.join("|")); -} - -function base64FromFloats(values: Float64Array): 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 base64FromBytes(bytes: Uint8Array): string { - 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 bytesFromBase64(encoded: string): Uint8Array { - 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 bytes; -} - -function floatsFromBase64(encoded: string): Float64Array { - 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 Float64Array(bytes.buffer); -} - -export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { - return { - version: ENVELOPE_VERSION, - hash, - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positionsBase64: base64FromFloats(ribbon.positions), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), - ...(ribbon.trimTris && ribbon.trimUvs - ? { - trimTrisBase64: base64FromFloats(ribbon.trimTris), - trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), - } - : {}), - })), - outline: build.outline, - caps: build.caps, - structures: build.structures, - planCurves: build.planCurves, - cutWalls: (build.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positionsBase64: base64FromFloats(wall.positions), - })), - }; -} - -export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { - return { - ribbons: envelope.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - colCount: ribbon.colCount, - chainages: ribbon.chainages, - positions: floatsFromBase64(ribbon.positionsBase64), - ...(ribbon.patch ? { patch: true } : {}), - ...(ribbon.patchClip ? { patchClip: true } : {}), - ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), - ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 - ? { - trimTris: floatsFromBase64(ribbon.trimTrisBase64), - trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), - } - : {}), - })), - outline: envelope.outline, - caps: envelope.caps ?? [], - structures: envelope.structures ?? [], - planCurves: envelope.planCurves ?? [], - cutWalls: (envelope.cutWalls ?? []).map((wall) => ({ - side: wall.side, - positions: floatsFromBase64(wall.positionsBase64), - })), - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts deleted file mode 100644 index 6ff5b3a61..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Legacy.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Legacy.ts - * 3D 옛 방식 A(115-4 · 08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」) — 비교용 빌드. - * - * 저장하지 않는다(저장본 · BUILD_VERSION · 해시 · 서버 사전생성 무관). [옛 방식 A] 단추를 - * 처음 켤 때 브라우저가 즉석으로 만들고 해시별로 메모한다. 입력은 본체 `refreshCorridor` - * 가 코리도를 뷰어에 넘길 때 그 빌드에 묶어 둔다. - * ========================================================================== */ - -import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; -import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { corridorHash } from "./B05_Profile_UI_Corridor_Envelope"; - -interface LegacyInputs { - detail: SectionDetailResponse; - routePoints: RoutePoint[]; - designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>; - hash?: string; -} - -const inputsOf = new WeakMap(); -let memo: { hash: string; build: CorridorBuildResult | null } | null = null; - -/** 본체 코리도 빌드에 옛 방식 A 입력을 묶어 둔다(메모리만). */ -export function rememberLegacyInputs(build: CorridorBuildResult, inputs: LegacyInputs): void { - inputsOf.set(build, inputs); -} - -function hashOf(inputs: LegacyInputs): string { - inputs.hash ??= corridorHash(inputs.detail, inputs.routePoints); - return inputs.hash; -} - -/** 이 빌드의 옛 방식 A 가 이미 만들어져 있는가(입력이 없으면 만들 것도 없다). */ -export function legacyReady(build: CorridorBuildResult): boolean { - const inputs = inputsOf.get(build); - return !inputs || memo?.hash === hashOf(inputs); -} - -/** 옛 방식 A 빌드 — 해시가 같으면 메모를 쓴다. 입력이 없거나 빌드 실패면 null. */ -export function legacyCorridorOf(build: CorridorBuildResult): CorridorBuildResult | null { - const inputs = inputsOf.get(build); - if (!inputs) return null; - const hash = hashOf(inputs); - if (memo?.hash !== hash) { - const { detail, routePoints, designSamples } = inputs; - memo = { - hash, - build: buildCorridor( - detail.cross_sections, - routePoints, - designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, - undefined, - { legacy: true }, - ), - }; - } - return memo.build; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts deleted file mode 100644 index 855fd5899..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Mesh.ts +++ /dev/null @@ -1,538 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Mesh.ts - * 코리도 리본(모델 좌표 격자) → Three.js 메쉬 그룹. - * - * 종류별 색으로 구분된 멀티 서피스 조합(2026-08-23 계획) — 하나의 서피스가 - * 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다. - * 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로 - * 변환해 담는다. - * - * 품질 4종(2026-08-23 사용자 확정): ① 지형 고도색(주황~녹)과 안 겹치는 색 - * 재설계 ② 종류 경계 윤곽선 ③ 절·성토 빗금(해칭) 텍스처 ④ 시·종점 마구리 봉인. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { - CorridorBuildResult, - CorridorCap, - CorridorKind, - CorridorRibbon, - CorridorStructure, - PlanCurve, -} from "./B05_Profile_UI_Corridor_Build"; - -/** - * 종류별 색 — 지형은 주황~황~녹 고도 램프라 그 대역을 피한다. - * 도로계는 무채색(회), 측구는 청, 절토는 자주(마젠타 계열), 성토는 청록. - * 절·성토를 적갈/초록으로 두면 지형색에 묻혀 구분이 안 됐다(2026-08-23 보고). - */ -const KIND_COLORS: Record = { - carriageway: 0x3f4451, - shoulder: 0x9aa2b1, - ditch: 0x38bdf8, - cut: 0xb5179e, - fill: 0x06b6d4, -}; - -/** 탑뷰 투영 커브 색(2026-08-26 사용자) — 종류별. 지형·코리도 색과 안 겹친다. */ -const PLAN_CURVE_COLORS: Record = { - structure: 0xffd93d, - "slope-deformed": 0xff2d78, - "slope-original": 0x38bdf8, - // 날개벽 성토끝 박스 — 구조물 노랑과 구분되게 주황(2026-08-26 사용자). - "wing-box": 0xff8c1a, - // 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정). - "slope-projected": 0xff3b30, - // 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 새 데이텀(+3m)에 얹어 라임으로 낸다. - "cut-merged": 0x84cc16, - // 측벽이 설 자리 — 벽 서피스로만 쓰고 선으로는 안 그린다(아래에서 걸러진다). - "cut-wall": 0x000000, -}; - -/** - * 평면 스케치(투영 커브) 묶음의 이름 — 뷰어가 이 이름으로 찾아 켜고 끈다. - * - * **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 이 커브들은 구조물이 어디를 지우고 - * 어디를 다시 채우는지 눈으로 좇으려고 만든 것이라 설계 산출물이 아니다. 다만 절취·패치 - * 기하를 다시 들여다볼 때 쓸모가 있어 **지우지 않고 감춰만 둔다**. - */ -export const PLAN_CURVE_GROUP = "corridor-plan-sketch"; - -/** 평면 스케치를 켜 둔 브라우저인가 — 기본은 꺼짐. 디버깅용 스위치다. */ -export const PLAN_CURVE_FLAG = "frd_debug_plan_curves"; - -export function planCurvesVisible(): boolean { - try { - return window.localStorage.getItem(PLAN_CURVE_FLAG) === "1"; - } catch { - return false; // 저장소가 막힌 환경(사생활 보호 모드 등) — 꺼진 것으로 본다. - } -} - -/** 유출측 파선 눈금(m) — 유입은 실선이다. */ -const PLAN_DASH_M = { dashSize: 0.6, gapSize: 0.4 }; - -/** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */ -const CAP_COLOR = 0x2a2f3a; - -/** 종류 경계 윤곽선 색·굵기 — 도면처럼 종류 경계를 또렷하게. */ -const OUTLINE_COLOR = 0x0f172a; - -/** 빗금(해칭) 방향 — 절토는 우상향, 성토는 좌상향으로 방향까지 구분한다. */ -const HATCH_DIRECTION: Partial> = { cut: 1, fill: -1 }; - -/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */ -const POLYGON_OFFSET_FACTOR = -1; - -/** 빗금 텍스처 캐시 — 리본마다 캔버스를 새로 그리면 편집 반복 시 낭비다. */ -const hatchCache = new Map(); - -/** 대각 빗금 캔버스 텍스처(반복). direction=1 우상향, -1 좌상향. */ -function hatchTexture(direction: 1 | -1): THREE.Texture { - const cached = hatchCache.get(direction); - if (cached) return cached; - const size = 32; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const context = canvas.getContext("2d"); - if (context) { - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, size, size); - context.strokeStyle = "rgba(0,0,0,0.42)"; - context.lineWidth = 3; - // 타일 경계에서 끊기지 않도록 3줄(−size, 0, +size)을 겹쳐 긋는다. - for (const shift of [-size, 0, size]) { - context.beginPath(); - if (direction > 0) { - context.moveTo(shift, size); - context.lineTo(shift + size, 0); - } else { - context.moveTo(shift, 0); - context.lineTo(shift + size, size); - } - context.stroke(); - } - } - const texture = new THREE.CanvasTexture(canvas); - texture.wrapS = THREE.RepeatWrapping; - texture.wrapT = THREE.RepeatWrapping; - hatchCache.set(direction, texture); - return texture; -} - -interface SceneOrigin { - cx: number; - cy: number; - cz: number; -} - -function originOf(bounds: ModelBounds): SceneOrigin { - return { - cx: (bounds.x[0] + bounds.x[1]) / 2, - cy: (bounds.y[0] + bounds.y[1]) / 2, - cz: (bounds.z[0] + bounds.z[1]) / 2, - }; -} - -function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry { - const rowCount = ribbon.chainages.length; - const cols = ribbon.colCount; - const positions = new Float32Array(rowCount * cols * 3); - // UV — u는 종방향 누가거리(m), v는 횡방향 열 비율. 빗금이 실척으로 반복된다. - const uvs = new Float32Array(rowCount * cols * 2); - const startChainage = ribbon.chainages[0]; - for (let row = 0; row < rowCount; row += 1) { - for (let col = 0; col < cols; col += 1) { - const i = row * cols + col; - const x = ribbon.positions[i * 3]; - const y = ribbon.positions[i * 3 + 1]; - const z = ribbon.positions[i * 3 + 2]; - positions[i * 3] = x - origin.cx; - positions[i * 3 + 1] = z - origin.cz; - positions[i * 3 + 2] = -(y - origin.cy); - uvs[i * 2] = (ribbon.chainages[row] - startChainage) / 2; // 2m마다 1타일. - uvs[i * 2 + 1] = col / Math.max(1, cols - 1); - } - } - const indices: number[] = []; - // 셀 마스크가 선 자리는 삼각형을 빼 그 자리를 비운다 — 투영 커브 안쪽 지우기 - // (2026-08-26). 정점은 그대로 두므로 남는 면의 형상은 하나도 안 바뀐다. - const mask = ribbon.cellMask; - for (let row = 0; row < rowCount - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - if (mask?.[row * (cols - 1) + col]) continue; - 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 trim = ribbon.trimTris; - const trimCount = trim ? trim.length / 3 : 0; - const allPositions = new Float32Array((rowCount * cols + trimCount) * 3); - const allUvs = new Float32Array((rowCount * cols + trimCount) * 2); - allPositions.set(positions); - allUvs.set(uvs); - if (trim) { - const base = rowCount * cols; - for (let v = 0; v < trimCount; v += 1) { - allPositions[(base + v) * 3] = trim[v * 3] - origin.cx; - allPositions[(base + v) * 3 + 1] = trim[v * 3 + 2] - origin.cz; - allPositions[(base + v) * 3 + 2] = -(trim[v * 3 + 1] - origin.cy); - allUvs[(base + v) * 2] = ribbon.trimUvs?.[v * 2] ?? 0; - allUvs[(base + v) * 2 + 1] = ribbon.trimUvs?.[v * 2 + 1] ?? 0; - if (v % 3 === 2) indices.push(base + v - 2, base + v - 1, base + v); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(allPositions, 3)); - geometry.setAttribute("uv", new THREE.BufferAttribute(allUvs, 2)); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - return geometry; -} - -/** 리본 양 가장자리(종류 경계) 선 — 도면처럼 경계를 또렷하게 한다. */ -function ribbonOutline(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry | null { - const rowCount = ribbon.chainages.length; - const cols = ribbon.colCount; - if (rowCount < 2) return null; - const points: number[] = []; - const sceneAt = (row: number, col: number): [number, number, number] => { - const i = (row * cols + col) * 3; - return [ - ribbon.positions[i] - origin.cx, - // 지형·서피스 위로 살짝 띄워 z-fight로 점선처럼 깜빡이는 것을 막는다. - ribbon.positions[i + 2] - origin.cz + 0.02, - -(ribbon.positions[i + 1] - origin.cy), - ]; - }; - const push = (row: number, col: number): void => { - points.push(...sceneAt(row, col)); - }; - for (const col of [0, cols - 1]) { - for (let row = 0; row < rowCount - 1; row += 1) { - push(row, col); - push(row + 1, col); - } - } - if (points.length < 6) return null; - return new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(points, 3), - ); -} - -/** 마구리(시·종점 절단면) — 설계선과 지반선 사이를 세로로 잇는 띠. */ -function capGeometry(cap: CorridorCap, origin: SceneOrigin): THREE.BufferGeometry | null { - if (cap.points.length < 2) return null; - const count = cap.points.length; - const positions = new Float32Array(count * 2 * 3); - cap.points.forEach(([x, y, zDesign, zGround], index) => { - const sceneX = x - origin.cx; - const sceneZ = -(y - origin.cy); - positions[index * 6] = sceneX; - positions[index * 6 + 1] = zDesign - origin.cz; - positions[index * 6 + 2] = sceneZ; - positions[index * 6 + 3] = sceneX; - positions[index * 6 + 4] = zGround - origin.cz; - positions[index * 6 + 5] = sceneZ; - }); - const indices: number[] = []; - for (let i = 0; i < count - 1; i += 1) { - const a = i * 2; - indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setIndex(indices); - geometry.computeVertexNormals(); - return geometry; -} - -/** 구조물 모서리 추출 임계각(도) — 이보다 꺾인 모서리만 검은선으로 긋는다. - * 스윕 분할선(거의 평평한 이음)은 걸러지고 단면 모서리·마구리만 남는다. */ -const STRUCTURE_EDGE_ANGLE_DEG = 18; - -/** 배수관 세트 구조물 색 — 기슭막이(돌빛 황갈), 집수정(콘크리트 회청), 배관(강관 청). */ -const STRUCTURE_COLORS: Record = { - revet: 0xc9a86a, - basin: 0x8d93a3, - pipe: 0x4f83cc, -}; - -/** 단면 폴리곤(offset/표고)을 스윕 프레임 열에 앉혀 노선을 따라 로프트한 솔리드 - * (2026-08-23 사용자 — 직선 압출이 아니라 노선 스윕). */ -function structureLoftGeometry( - structure: CorridorStructure, - origin: SceneOrigin, -): THREE.BufferGeometry | null { - const rings = structure.rings; - // 링별 폴리곤이 오면 그것을, 아니면 공통 폴리곤 하나를 모든 링에 쓴다(2026-08-25). - const perRing = structure.polygons; - const polygon = perRing?.[0] ?? structure.polygon; - if (!polygon || polygon.length < 3 || !rings || rings.length < 2) return null; - if (perRing && perRing.length !== rings.length) return null; - const count = polygon.length; - const ringCount = rings.length; - const positions = new Float32Array(ringCount * count * 3); - rings.forEach((frame, ring) => { - (perRing?.[ring] ?? polygon).forEach(([offset, elevation], index) => { - const x = frame.cx + frame.leftX * offset; - const y = frame.cy + frame.leftY * offset; - const i = (ring * count + index) * 3; - positions[i] = x - origin.cx; - // 종단 경사 — 프레임마다 계획고 차이를 얹는다(2026-08-23 사용자). - positions[i + 1] = elevation + frame.dz - origin.cz; - positions[i + 2] = -(y - origin.cy); - }); - }); - const indices: number[] = []; - const lastBase = (ringCount - 1) * count; - for (let i = 1; i < count - 1; i += 1) { - indices.push(0, i, i + 1); // 앞 마구리(부채꼴 — 단면 폴리곤은 볼록 사각형이다) - indices.push(lastBase, lastBase + i + 1, lastBase + i); // 뒷 마구리(반대 감김) - } - for (let ring = 0; ring < ringCount - 1; ring += 1) { - const a0 = ring * count; - const b0 = a0 + count; - for (let i = 0; i < count; i += 1) { - const j = (i + 1) % count; - indices.push(a0 + i, b0 + i, a0 + j, a0 + j, b0 + i, b0 + j); - } - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setIndex(indices); - // 면마다 자기 법선(flat) — 인덱스를 공유한 채 평균 법선을 쓰면 모서리가 뭉개져 - // 구조물이 찌그러져 보인다(2026-08-23 사용자 지적: 엣지를 살릴 것). - const flat = geometry.toNonIndexed(); - geometry.dispose(); - flat.computeVertexNormals(); - return flat; -} - -/** - * 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 **중공 튜브**(2026-08-25 사용자: - * 내부 유로 반영). 바깥·안쪽 원통을 열린 채로 겹치고 양 끝을 고리로 막는다. - */ -function structurePipeMesh( - structure: CorridorStructure, - origin: SceneOrigin, -): THREE.Object3D | null { - const pipe = structure.pipe; - const frame = structure.frame; - if (!pipe || !frame) return null; - const { cx, cy, leftX, leftY } = frame; - const toScene = ([offset, elevation]: [number, number]): THREE.Vector3 => - new THREE.Vector3( - cx + leftX * offset - origin.cx, - elevation - origin.cz, - -(cy + leftY * offset - origin.cy), - ); - const start = toScene(pipe.start); - const end = toScene(pipe.end); - const axis = end.clone().sub(start); - const length = axis.length(); - if (!(length > 0.1)) return null; - // 관 하단선이 축이므로 반지름만큼 위로 올려 관 중심을 맞춘다. - const radius = pipe.diameterM / 2; - // 관벽 두께가 없으면 관경의 2%를 임시로 준다 — 면이 겹쳐 z-파이팅이 나지 않을 정도. - const wall = Math.min(Math.max(pipe.wallThicknessM ?? pipe.diameterM * 0.02, 0.01), radius * 0.9); - const inner = radius - wall; - const material = (side: THREE.Side): THREE.Material => - new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe, side }); - const group = new THREE.Group(); - group.add( - new THREE.Mesh( - new THREE.CylinderGeometry(radius, radius, length, 20, 1, true), - material(THREE.FrontSide), - ), - // 안쪽 벽은 관 속에서 보이므로 법선을 뒤집는다(내부 유로 면). - new THREE.Mesh( - new THREE.CylinderGeometry(inner, inner, length, 20, 1, true), - material(THREE.BackSide), - ), - ); - // 양 끝 관벽 단면 — 고리로 막아 튜브가 열린 파이프로 보이게 한다. - for (const sign of [1, -1]) { - const ring = new THREE.Mesh( - new THREE.RingGeometry(inner, radius, 20), - material(THREE.DoubleSide), - ); - ring.rotation.x = Math.PI / 2; - ring.position.y = (sign * length) / 2; - group.add(ring); - } - group.position.copy(start.clone().add(end).multiplyScalar(0.5)); - group.position.y += radius; - group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize()); - return group; -} - -/** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */ -export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group { - const group = new THREE.Group(); - group.name = "corridor"; - const origin = originOf(bounds); - build.ribbons.forEach((ribbon) => { - if (ribbon.chainages.length < 2) return; - const direction = HATCH_DIRECTION[ribbon.kind]; - const material = new THREE.MeshLambertMaterial({ - color: KIND_COLORS[ribbon.kind], - // 절·성토만 빗금 — 도면 관례대로 방향으로도 구분된다. - map: direction ? hatchTexture(direction) : null, - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }); - // 도로부(차도·노견·측구)는 구조물 위를 지나므로 도려내지 않는다 — 노면은 복토 - // 위를 덮는 게 맞다. 절·성토 비탈만 구조물에 자리를 내준다. - const raw = ribbonGeometry(ribbon, origin); - // 절단은 Build가 행 단면 트림으로 이미 끝냈다(2026-08-25) — 여기서는 그대로 그린다. - const mesh = new THREE.Mesh(raw, material); - mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`; - group.add(mesh); - const outline = ribbonOutline(ribbon, origin); - if (outline) { - const line = new THREE.LineSegments( - outline, - new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.8 }), - ); - line.name = `corridor-outline:${ribbon.kind}:${ribbon.side}`; - group.add(line); - } - }); - // 탑뷰 투영 커브(2026-08-26 사용자) — 구조물 세트마다 **그 세트 구조물 상단 +5m** - // 평면에 눕힌 닫힌 고리다. 종류는 색(구조물 노랑 / 변형 성토선 분홍 / 원본 하늘), - // 유입·유출은 실선·파선으로 나눈다. 표기 전용 — 서피스는 안 건드린다. - // - // **최종 결과물에서는 숨긴다**(2026-09-02 사용자). 지우지는 않는다 — 절취·패치 기하를 - // 다시 들여다볼 때 쓰므로 묶음째 감춰 두고 `__corridorPlanCurves(true)` 로 되켠다. - const planGroup = new THREE.Group(); - planGroup.name = PLAN_CURVE_GROUP; - planGroup.visible = planCurvesVisible(); - group.add(planGroup); - (build.planCurves ?? []).forEach((curve) => { - // 벽 자리 커브는 표시용이 아니다 — 벽 서피스로만 나간다(2026-08-27 사용자). - if (curve.source === "cut-wall") return; - const color = PLAN_CURVE_COLORS[curve.source]; - const dashed = curve.role === "outlet"; - curve.loops.forEach((loop, index) => { - if (loop.length < 2) return; - const positions: number[] = []; - // 점의 z를 그대로 쓴다 — 평면 커브는 z에 planZ가 들어 있어 동작이 같고, - // 투영선(`slope-projected`)만 점마다 절·성토면 표고를 들고 온다(2026-08-26). - for (const [x, y, z] of loop) { - positions.push(x - origin.cx, z - origin.cz, -(y - origin.cy)); - } - const geometry = new THREE.BufferGeometry().setAttribute( - "position", - new THREE.Float32BufferAttribute(positions, 3), - ); - const line = new THREE.Line( - geometry, - dashed - ? new THREE.LineDashedMaterial({ color, ...PLAN_DASH_M }) - : new THREE.LineBasicMaterial({ color }), - ); - if (dashed) line.computeLineDistances(); - const station = curve.setChainageM.toFixed(2); - line.name = `corridor-plan:${curve.source}:${curve.role}:${curve.side}:${station}:${index}`; - planGroup.add(line); - }); - }); - // 절취 측벽 — 리본과 분리된 **별도 서피스**(2026-08-27 사용자). 성토면과 같은 색·빗금이라 - // 한 몸으로 보이지만 객체가 달라 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. - (build.cutWalls ?? []).forEach((wall, index) => { - const count = wall.positions.length / 3; - if (!count) return; - const positions = new Float32Array(count * 3); - for (let v = 0; v < count; v += 1) { - positions[v * 3] = wall.positions[v * 3] - origin.cx; - positions[v * 3 + 1] = wall.positions[v * 3 + 2] - origin.cz; - positions[v * 3 + 2] = -(wall.positions[v * 3 + 1] - origin.cy); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - if (wall.uvs && wall.uvs.length === count * 2) { - geometry.setAttribute("uv", new THREE.BufferAttribute(wall.uvs, 2)); - } - geometry.computeVertexNormals(); - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ - // 절취 측벽은 **일반 서피스**(빗금 없음) — 벽은 방향성을 표현할 면이 아니다 - // (2026-08-27 사용자). 패치 스커트는 성토면의 연장이라 **빗금 있는 메시 - // 서피스**로 그린다(2026-08-27 사용자) — UV를 싣고 온 판만 해당된다. - color: KIND_COLORS.fill, - map: wall.uvs ? hatchTexture(HATCH_DIRECTION.fill ?? -1) : null, - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }), - ); - mesh.name = wall.patchSkirt - ? `corridor-patch-skirt:${wall.side}:${index}` - : `corridor-cut-wall:${wall.side}:${index}`; - group.add(mesh); - }); - build.caps.forEach((cap, index) => { - const geometry = capGeometry(cap, origin); - if (!geometry) return; - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ color: CAP_COLOR, side: THREE.DoubleSide }), - ); - mesh.name = `corridor-cap:${index}`; - group.add(mesh); - }); - // 배수관 세트 구조물(기슭막이·집수정·배관) — B06 횡단 계산 그대로의 솔리드 - // (2026-08-23 사용자). 리본 격자와 무관한 독립 물체라 개별 Mesh로 얹는다. - (build.structures ?? []).forEach((structure, index) => { - // 신원표 — 3D 클릭이 이 값으로 부재를 가려낸다(2026-09-04). 이름 규칙은 그대로 둔다 - // (다른 코드가 이름으로 찾는다). 배관은 겉·속 두 겹이라 자식까지 같은 표를 단다. - const tag = { chainageM: structure.chainage_m, kind: structure.kind, key: structure.key }; - if (structure.kind === "pipe") { - const mesh = structurePipeMesh(structure, origin); - if (mesh) { - mesh.name = `corridor-structure:pipe:${index}`; - mesh.traverse((object) => { - object.userData = tag; - }); - group.add(mesh); - } - return; - } - const geometry = structureLoftGeometry(structure, origin); - if (!geometry) return; - const mesh = new THREE.Mesh( - geometry, - new THREE.MeshLambertMaterial({ - color: STRUCTURE_COLORS[structure.kind], - side: THREE.DoubleSide, - polygonOffset: true, - polygonOffsetFactor: POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: POLYGON_OFFSET_FACTOR, - }), - ); - mesh.name = `corridor-structure:${structure.kind}:${index}`; - mesh.userData = tag; - group.add(mesh); - // 모서리 검은선 — 성토부·노폭 리본과 같은 규칙으로 경계를 또렷하게 - // (2026-08-23 사용자). 임계각 위 모서리만 뽑아 스윕 분할선은 남기지 않는다. - const edges = new THREE.LineSegments( - new THREE.EdgesGeometry(geometry, STRUCTURE_EDGE_ANGLE_DEG), - new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.9 }), - ); - edges.name = `corridor-structure-edge:${structure.kind}:${index}`; - group.add(edges); - }); - return group; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts deleted file mode 100644 index eb73d357d..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Modes.ts +++ /dev/null @@ -1,207 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Modes.ts - * 3D 예상형상 **방식 등록부**(계획서 115-5) — 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① · - * 제안 ②」 고르기 한 줄이 이 표를 본다. - * - * 방식 하나 = 이름 + 만들기 함수 둘 — `corridor`(코리도 그룹에 그릴 빌드) · `terrain`(지형 - * · 없으면 그 빌드로 띠만 자른다). 제안 ② 는 다른 창이 새 파일을 만들고 아래 - * `CORRIDOR_MODES` 에 한 줄 등록한다. 등록이 없으면 그 단추는 꺼진다. - * 설정 키는 `corridor-mode` 하나 — 옛 두 단추(115-1 `corridor-deform` · 115-4 - * `corridor-legacy`) 값은 읽어서 옮긴다. 기본 = 새 ①. - * ========================================================================== */ - -import type * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; -import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; -import { tinTerrain } from "./B05_Profile_UI_Corridor_Tin"; -import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft"; -import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; - -/** 고르기 한 줄의 자리 — 순서 · 이름. 등록이 없는 자리는 단추가 꺼진다. */ -export const MODE_SLOTS = [ - ["current", "지금"], - ["legacy-a", "옛 A"], - ["new1", "새 ①"], - ["proposal2", "제안 ②"], - ["proposal3", "제안 ③"], -] as const; - -export type CorridorModeId = (typeof MODE_SLOTS)[number][0]; - -export const DEFAULT_MODE: CorridorModeId = "new1"; - -/** 만들기 함수가 받는 것 — 모델 좌표 범위와 지형 높이 색인(없으면 null). */ -export interface CorridorModeContext { - bounds: ModelBounds; - heightIndex: TerrainHeightIndex | null; -} - -export interface CorridorModeTerrainInput extends CorridorModeContext { - /** 본체 빌드(종횡단 정본 그대로 · 읽기만). */ - build: CorridorBuildResult; - /** 이 방식의 `corridor` 가 낸 빌드. */ - corridor: CorridorBuildResult; - /** 지형 밴드 분할본(near/far) — 읽기만 · 사본을 만들어 쓴다. */ - split: TerrainBandSplit; -} - -export interface CorridorMode { - id: CorridorModeId; - /** 처음 만들기가 무거운가 — 참이면 알림을 먼저 띄우고 다음 틈에 만든다. */ - slow?: (build: CorridorBuildResult) => boolean; - /** 코리도 그룹에 그릴 빌드 — null 이면 기본 방식으로 그린다. */ - corridor: (build: CorridorBuildResult, ctx: CorridorModeContext) => CorridorBuildResult | null; - /** 지형 — 없거나 null 이면 `corridor` 빌드로 띠만 자른다(`clipTerrain`). */ - terrain?: (input: CorridorModeTerrainInput) => { root: THREE.Object3D; stats?: unknown } | null; -} - -/** 모델 좌표 원지반 높이 — 색인이 없으면 null. */ -function groundOf(ctx: CorridorModeContext): ((x: number, y: number) => number | null) | null { - const index = ctx.heightIndex; - if (!index) return null; - const ox = (ctx.bounds.x[0] + ctx.bounds.x[1]) / 2; - const oy = (ctx.bounds.y[0] + ctx.bounds.y[1]) / 2; - const oz = (ctx.bounds.z[0] + ctx.bounds.z[1]) / 2; - return (x, y) => { - const height = index.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; -} - -/** - * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. - * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 - * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). - */ -function snapCorridorEdges(build: CorridorBuildResult, ctx: CorridorModeContext): void { - const groundAt = groundOf(ctx); - if (!groundAt) return; - build.ribbons.forEach((ribbon) => { - // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). - // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; - // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 - // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 - // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). - if (ribbon.patch) 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 height = groundAt(ribbon.positions[outer], ribbon.positions[outer + 1]); - if (height !== null) ribbon.positions[outer + 2] = height; - } - }); -} - -/** - * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 - * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 - * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. - */ -function attachPatchSkirts(build: CorridorBuildResult, ctx: CorridorModeContext): void { - const groundAt = groundOf(ctx); - if (!groundAt) return; - const { walls, stats } = buildPatchSkirts(build.ribbons, groundAt); - const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); - build.cutWalls = [...kept, ...walls]; - (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; -} - -/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ -function roadOnly(build: CorridorBuildResult): CorridorBuildResult { - return { - ...build, - ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"), - planCurves: [], - cutWalls: [], - }; -} - -/** 방식 등록부 — 제안 ② 는 아래 주석 자리에 한 줄. */ -export const CORRIDOR_MODES: CorridorMode[] = [ - // 지금 — 비탈 리본 · 스케치 · 패치 · 지형은 띠만 자름(115-1 전 방식). - { - id: "current", - corridor: (build, ctx) => { - snapCorridorEdges(build, ctx); - attachPatchSkirts(build, ctx); - return build; - }, - }, - // 옛 A(115-4) — 08-25 실루엣 갈아 끼우기 · 저장 안 함(브라우저 즉석 · 해시별 메모). - { - id: "legacy-a", - slow: (build) => !legacyReady(build), - corridor: (build, ctx) => { - const legacy = legacyCorridorOf(build); - if (legacy) snapCorridorEdges(legacy, ctx); - return legacy; - }, - }, - // 새 ①(115-1 · 115-5) — 노면부만 그리고 비탈은 지형 메시 변형 + 브레이크라인. - { - id: "new1", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? deformTerrain(split, build, bounds, heightIndex) : null, - }, - // 제안 ②(115-6) — 노면부만 그리고 비탈 · 지형 이음은 단일 삼각망(TIN). - { - id: "proposal2", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? tinTerrain(split, build, bounds, heightIndex) : null, - }, - // 제안 ③(115-8) — 노면부만 그리고 비탈은 측점 횡단 보간(평면도 외곽선 규칙) · 지형 이음은 ② 기계. - { - id: "proposal3", - corridor: (build) => roadOnly(build), - terrain: ({ split, build, bounds, heightIndex }) => - heightIndex ? loftTerrain(split, build, bounds, heightIndex) : null, - }, -]; - -export function modeOf(id: CorridorModeId): CorridorMode | null { - return CORRIDOR_MODES.find((mode) => mode.id === id) ?? null; -} - -const listeners = new Set<(id: CorridorModeId) => void>(); - -/** 고른 방식 — 저장값이 없으면 옛 두 단추 값을 옮겨 읽는다. */ -export function corridorModeId(): CorridorModeId { - const saved = readStateRaw("corridor-mode"); - const known = MODE_SLOTS.find(([id]) => id === saved); - if (known) return known[0]; - if (readStateRaw("corridor-legacy") === "1") return "legacy-a"; - if (readStateRaw("corridor-deform") === "0") return "current"; - return DEFAULT_MODE; -} - -/** 실제로 그릴 방식 — 고른 자리가 비었으면(제안 ② 미등록) 기본. */ -export function activeCorridorMode(): CorridorMode { - return modeOf(corridorModeId()) ?? (modeOf(DEFAULT_MODE) as CorridorMode); -} - -export function setCorridorMode(id: CorridorModeId): void { - writeStateRaw("corridor-mode", id); - listeners.forEach((listener) => listener(id)); -} - -export function onCorridorMode(listener: (id: CorridorModeId) => void): () => void { - listeners.add(listener); - return () => listeners.delete(listener); -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts deleted file mode 100644 index e1c179cfd..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan.ts +++ /dev/null @@ -1,629 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Plan.ts - * 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자 확정). - * - * 규칙: - * · 투영 평면 = **구조물 세트당 하나** — 그 세트 구조물 최고 표고 + 5m. - * 세트끼리 높이가 다르다(경사 임도에서 각 구조물 위 5m를 지킨다). - * · 유입/유출을 **따로** 낸다 — 도로 기준으로 유입·유출 형태가 다를 수 있다. - * 상단측(`uphill_side`)이 유입이다. - * · 도로부(노견 안쪽)는 전 커브에서 뺀다 — 좌우가 끊겨 세트당 두 루프가 된다. - * · 구조물 커브와 비탈 커브는 **합치지 않는다**(사용자: 따로 2개). - * · 비탈 커브는 **변형 실루엣**·**원본 성토선** 두 벌을 다 낸다 — 어느 쪽을 - * 남길지는 화면 확인 후 별도 확정(2026-08-26 사용자). - * - * 절단은 하지 않는다 — 어디가 덮이는지 눈으로 보고 판단하기 위한 표기다. - * ========================================================================== */ - -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure, RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; -import { assembleStripLoops } from "./B05_Profile_UI_Corridor_Carve"; -import { - castToToe, - structureFootprintLoops, - wingBoxLoops, -} from "./B05_Profile_UI_Corridor_Plan_Footprint"; -import { mixedGround, trimSlopeToGround } from "./B05_Profile_UI_Corridor_Station"; -import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; -import type { PXY } from "./B05_Profile_UI_Corridor_Union"; - -export type PlanCurveSource = - | "structure" - | "slope-deformed" - | "slope-original" - | "wing-box" - /** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선** - * (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */ - | "slope-projected" - /** 측벽이 설 자리(2026-08-27 사용자) — 절취 커브에서 **노견선·성토 끝선 변을 뺀** - * 열린 폴리라인. 스트립은 양 끝 횡단선, 날개 사다리꼴은 [끝선→날개→끝선]이다. - * 표시는 하지 않고 벽 세우기에만 쓴다. */ - | "cut-wall" - /** 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 비탈 투영선 ∪ 날개벽 사다리꼴을 합쳐 - * 셀 마스크에서 되짚은 커브. **현 평면 대비 +3m 새 데이텀**에 얹는다. */ - | "cut-merged"; - -/** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */ -export interface PlanCurve { - /** 세트 소유 측점 누가거리(m) — 세트 식별자. */ - setChainageM: number; - source: PlanCurveSource; - role: "inlet" | "outlet"; - side: "left" | "right"; - /** 투영 평면 표고(모델 z) = 세트 구조물 최고 표고 + 5m. */ - planZ: number; - /** 모델 좌표 닫힌 고리들(z는 전부 planZ). */ - loops: Array>; - /** - * 가로 방향이 **노선이 아니라 구조물 축에 고정**된 커브(BOX암거). 커브가 코리도 - * 격자와 비스듬해 셀 마스크만으로는 경계가 지그재그가 된다 — 절단 쪽에서 이 커브만 - * 골라 셀을 커브로 쪼갠다(2026-08-26 사용자: 이원화하여 관리). - */ - axisLocked?: boolean; -} - -/** 구조물 상단에서 투영 평면을 띄우는 높이(m) — 2026-08-26 사용자. */ -const PLANE_LIFT_M = 5; - -/** 비탈 스트립 종방향 걸음(m). */ -const STEP_M = 0.5; - -/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ -const MIN_WIDTH_M = 0.05; - -/** 코리도 바깥 윤곽(행별 좌·우 최외곽 XY) — `CorridorBuildResult.outline`. */ -export interface CorridorOutline { - chainages: number[]; - left: Array<[number, number]>; - right: Array<[number, number]>; -} - -type Side = "left" | "right"; - -/** 편거리를 실좌표로 — 노선 프레임이든 구조물 링 프레임이든 좌향 벡터만 있으면 된다. */ -interface PlanFrame { - cx: number; - cy: number; - leftX: number; - leftY: number; -} - -/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */ -export function buildGroundAt( - crossSections: ReadonlyArray, -): (chainageM: number) => ((offset: number) => number) | null { - const rows = crossSections - .map((section) => ({ - chainage: section.chainage_m, - ground: groundInterpolator(section.samples), - })) - .sort((a, b) => a.chainage - b.chainage); - return (chainageM) => { - if (!rows.length) return null; - for (let i = 0; i < rows.length - 1; i += 1) { - const lo = rows[i]; - const hi = rows[i + 1]; - if (chainageM < lo.chainage || chainageM > hi.chainage) continue; - const span = hi.chainage - lo.chainage; - const t = span > 1e-6 ? (chainageM - lo.chainage) / span : 0; - return mixedGround(lo.ground, hi.ground, t); - } - return chainageM <= rows[0].chainage ? rows[0].ground : rows[rows.length - 1].ground; - }; -} - -function at(frame: PlanFrame, offset: number): PXY { - return { x: frame.cx + frame.leftX * offset, y: frame.cy + frame.leftY * offset }; -} - -/** - * 성토 끝선에서 세트 근처 구간만 — 노선 누가거리 ± 이만큼(m)을 본다. - * - * 넉넉해야 한다. 곡선 안쪽 측은 종방향이 압축돼 누가거리 20m를 잘라도 측점 로컬 - * 종방향으로는 5.2m밖에 안 나왔고, 종방향 6.4m가 필요한 날개가 끝선을 못 만났다 - * (2026-08-26 실측, 149.73 세월교 우측). 엉뚱한 자리에 걸리는 건 `RAY_LIMIT_M`이 막는다. - */ -const TOE_WINDOW_M = 40; - -/** 그 측 성토 끝선(코리도 바깥 윤곽)에서 세트 근처 마디만 잘라낸다. */ -function nearToe( - outline: CorridorOutline, - side: Side, - setChainageM: number, -): Array<[number, number]> { - const points: Array<[number, number]> = []; - outline.chainages.forEach((chainageM, index) => { - if (Math.abs(chainageM - setChainageM) > TOE_WINDOW_M) return; - points.push(side === "left" ? outline.left[index] : outline.right[index]); - }); - return points; -} - -/** 세트 구조물의 최고 표고 — 폴리곤 표고에 링 종단 보정(dz)을 더한 값의 최대. */ -function topElevationOf(set: ReadonlyArray): number | null { - let top: number | null = null; - for (const structure of set) { - const rings = structure.rings; - if (!rings || !rings.length) continue; - const perRing = structure.polygons; - const shared = structure.polygon; - rings.forEach((frame, index) => { - const polygon = perRing?.[index] ?? shared; - if (!polygon) return; - for (const [, elevation] of polygon) { - const z = elevation + frame.dz; - if (top === null || z > top) top = z; - } - }); - } - return top; -} - -/** 스트립 행([안쪽, 바깥쪽] XY)을 평면 표고에 얹어 닫힌 고리로 만든다. */ -function stripLoops( - rows: ReadonlyArray<[PXY, PXY] | null>, - planZ: number, -): Array> { - return assembleStripLoops( - rows.map((row) => - row - ? ([ - [row[0].x, row[0].y, planZ], - [row[1].x, row[1].y, planZ], - ] as [[number, number, number], [number, number, number]]) - : null, - ), - ); -} - -/** 두 점이 커브를 만들 만큼 떨어졌나. */ -function apart(a: PXY, b: PXY): boolean { - return Math.hypot(b.x - a.x, b.y - a.y) > MIN_WIDTH_M; -} - -/** 절·성토면 조회 묶음 — 표고와 **면이 시작하는 안쪽 끝**을 같은 색인에서 낸다. */ -interface SurfaceLookup { - /** 그 행·그 측 서피스의 z. 밑에 면이 없으면 null. */ - zAt: (chainageM: number, side: Side, x: number, y: number) => number | null; - /** - * 그 행·그 측 서피스가 **실제로 시작하는 안쪽 끝**(노견 확장·측구를 이미 반영한 - * 자리). 파란 스케치의 안쪽 기준을 여기에 맞춰야 커브가 면 경계와 겹친다 - * (2026-08-26 사용자 지적: 노견 확장 개념 미반영). - */ - innerAt: (chainageM: number, side: Side, nearX: number, nearY: number) => PXY | null; -} - -function buildSurfaceZAt(ribbons: ReadonlyArray): SurfaceLookup { - /** - * `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나 - * **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다. - * - * 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라 - * 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교 - * 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번 - * 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다. - */ - const rowsAt = new Map>>(); - for (const ribbon of ribbons) { - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") continue; - ribbon.chainages.forEach((chainageM, row) => { - const cols: Array<[number, number, number]> = []; - for (let col = 0; col < ribbon.colCount; col += 1) { - const i = (row * ribbon.colCount + col) * 3; - cols.push([ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]); - } - if (ribbon.side === "right") cols.reverse(); - const key = `${ribbon.side}|${chainageM}`; - const bucket = rowsAt.get(key); - if (bucket) bucket.push(cols); - else rowsAt.set(key, [cols]); - }); - } - /** - * 폴리라인 위 XY 최근접점의 z. 폴리라인 밖이면 양 끝으로 클램프된다. - * - * 단면에는 **거의 수직인 면**(다단 기슭막이 벽 앞면)이 있어 같은 XY에 z가 여러 개 - * 걸린다 — 탑뷰 투영이 원래 모호한 자리다. 거리가 같으면 **바깥 열이 이기게** 한다: - * 열은 안쪽→바깥 순이고, 커브 바깥 끝이 뜻하는 자리는 벽 상단이 아니라 **성토 끝선** - * 이다(2026-08-26 실측: 275.71 좌측에서 끝선이 0.69m 떠 있었다). - */ - const zOnRow = ( - cols: Array<[number, number, number]>, - x: number, - y: number, - ): { distance: number; z: number } | null => { - let best: { distance: number; z: number } | null = null; - for (let i = 1; i < cols.length; i += 1) { - const [ax, ay, az] = cols[i - 1]; - const [bx, by, bz] = cols[i]; - const dx = bx - ax; - const dy = by - ay; - const lengthSq = dx * dx + dy * dy; - const t = - lengthSq > 1e-12 ? Math.min(1, Math.max(0, ((x - ax) * dx + (y - ay) * dy) / lengthSq)) : 0; - const px = ax + dx * t; - const py = ay + dy * t; - const distance = Math.hypot(x - px, y - py); - const z = az + (bz - az) * t; - if (!best || distance < best.distance - 1e-6) best = { distance, z }; - // 무승부(수직면) — 뒤에 오는 바깥 열로 갈아탄다. 최소 거리는 유지한다. - else if (distance < best.distance + 1e-6) - best = { distance: Math.min(distance, best.distance), z }; - } - return best; - }; - return { - zAt: (chainageM, side, x, y) => { - const buckets = rowsAt.get(`${side}|${chainageM}`); - if (!buckets) return null; - let best: { distance: number; z: number } | null = null; - for (const cols of buckets) { - // 정점에 얹힌 XY(면 안쪽 끝·바깥 끝)는 **그 정점 z를 그대로** 쓴다 — 보간· - // 최근접 판정을 태우면 수직면(다단 벽 앞면)에서 엉뚱한 단이 이긴다. 허용오차는 - // 1mm — 1e-6으로 잡았더니 끝점이 0.5mm 어긋난 행에서 최대 1.01m 튀었다 - // (2026-08-26 실측, 세월교 149.73). 열은 안쪽→바깥이라 무승부면 바깥이 이긴다. - let exact: { distance: number; z: number } | null = null; - for (const [px, py, pz] of cols) { - const distance = Math.hypot(x - px, y - py); - if (distance > VERTEX_SNAP_M) continue; - if (!exact || distance < exact.distance + 1e-9) exact = { distance, z: pz }; - } - const hit = exact ?? zOnRow(cols, x, y); - if (!hit) continue; - if (!best || hit.distance < best.distance) best = hit; - } - return best ? best.z : null; - }, - innerAt: (chainageM, side, nearX, nearY) => { - const buckets = rowsAt.get(`${side}|${chainageM}`); - if (!buckets) return null; - // 한 행에 절·성토가 같이 있으면 **도로에 가까운 쪽**의 안쪽 끝을 쓴다. - let best: { distance: number; point: PXY } | null = null; - for (const cols of buckets) { - const [x, y] = cols[0]; - const distance = Math.hypot(nearX - x, nearY - y); - if (!best || distance < best.distance) best = { distance, point: { x, y } }; - } - return best ? best.point : null; - }, - }; -} - -/** 표고 조회에 필요한 리본 최소 형태 — Build의 `CorridorRibbon`이 그대로 들어맞는다. */ -export interface CorridorRibbonLike { - kind: string; - /** "left" | "right" | "center" — 비탈 리본은 좌·우뿐이라 center는 걸러진다. */ - side: string; - colCount: number; - chainages: ReadonlyArray; - positions: ArrayLike; -} - -/** 투영 점을 리본 정점으로 붙여 보는 허용오차(m) — 수직면에서 단이 뒤바뀌는 것 방지. */ -const VERTEX_SNAP_M = 0.001; - -/** 투영선을 서피스에서 띄우는 높이(m) — z-fighting 방지. */ -const PROJECT_LIFT_M = 0.02; - -/** XY 다각형 안팎 판정(광선 투사). */ -function insideRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) { - const [xi, yi] = ring[i]; - const [xj, yj] = ring[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 벽 자리를 **머지 영역의 경계**만 남긴다(2026-08-27 사용자). - * - * 절취 영역은 본 구조물 스트립과 날개 사다리꼴을 **합친 것**이라, 한쪽 영역의 변이 - * 다른 영역 **안쪽**을 지나면 그건 경계가 아니라 속살이다. 변을 다른 영역 테두리와의 - * 교점에서 쪼갠 뒤, 조각 가운데가 다른 영역 안이면 버린다. - */ -function trimToUnionBoundary( - parts: ReadonlyArray<{ points: ReadonlyArray<[number, number, number]>; owner: number }>, - areas: ReadonlyArray>, -): Array> { - const lines: Array> = []; - for (const part of parts) { - for (let i = 0; i < part.points.length - 1; i += 1) { - const from = part.points[i]; - const to = part.points[i + 1]; - const rx = to[0] - from[0]; - const ry = to[1] - from[1]; - if (Math.hypot(rx, ry) < 1e-9) continue; - const cuts = [0, 1]; - areas.forEach((ring, index) => { - if (index === part.owner) return; - for (let k = 0, j = ring.length - 1; k < ring.length; j = k, k += 1) { - const sx = ring[k][0] - ring[j][0]; - const sy = ring[k][1] - ring[j][1]; - const denominator = rx * sy - ry * sx; - if (Math.abs(denominator) < 1e-12) continue; - const dx = ring[j][0] - from[0]; - const dy = ring[j][1] - from[1]; - const t = (dx * sy - dy * sx) / denominator; - const u = (dx * ry - dy * rx) / denominator; - if (t > 1e-9 && t < 1 - 1e-9 && u >= 0 && u <= 1) cuts.push(t); - } - }); - cuts.sort((a, b) => a - b); - const length = Math.hypot(rx, ry); - for (let c = 0; c < cuts.length - 1; c += 1) { - const t0 = cuts[c]; - const t1 = cuts[c + 1]; - // 2cm 미만 조각은 버린다 — 교점이 겹쳐 생기는 부스러기다. - if ((t1 - t0) * length < 0.02) continue; - const mid = (t0 + t1) / 2; - const mx = from[0] + rx * mid; - const my = from[1] + ry * mid; - const buried = areas.some( - (ring, index) => index !== part.owner && insideRing(ring, mx, my), - ); - if (buried) continue; - const at = (t: number): [number, number, number] => [ - from[0] + rx * t, - from[1] + ry * t, - from[2] + (to[2] - from[2]) * t, - ]; - lines.push([at(t0), at(t1)]); - } - } - } - return lines; -} - -/** 스트립 행을 **점마다 다른 표고**로 얹어 닫힌 고리로 만든다(투영선용). */ -function projectedLoops( - rows: ReadonlyArray<[[number, number, number], [number, number, number]] | null>, -): Array> { - return assembleStripLoops(rows); -} - -/** - * 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음 - * (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌). - */ -export function buildPlanCurves( - crossSections: ReadonlyArray, - structures: ReadonlyArray, - frameAt: (chainageM: number) => RouteFrame | null, - outline: CorridorOutline, - /** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */ - ribbons: ReadonlyArray = [], -): PlanCurve[] { - const curves: PlanCurve[] = []; - const groundAt = buildGroundAt(crossSections); - const surface = buildSurfaceZAt(ribbons); - for (const section of crossSections) { - const set = structures.filter( - (structure) => Math.abs(structure.chainage_m - section.chainage_m) < 1e-6, - ); - if (!set.length) continue; - const top = topElevationOf(set); - if (top === null) continue; - const planZ = top + PLANE_LIFT_M; - const edges = section.design?.road_edges; - if (!edges) continue; - const fallback: RouteFrame = { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - designZ: null, - }; - const routeFrameOf = (chainageM: number): RouteFrame => frameAt(chainageM) ?? fallback; - /** - * BOX암거는 비탈 커브도 **박스 방향 하나**로 긋는다(2026-08-26 사용자: 날개벽처럼 - * 단일방향). 행 자리는 노선 위 그대로 두고 **가로 방향만** 기준 측점 좌향으로 - * 고정한다 — 곡선에서 행마다 방향이 틀어져 커브가 뒤틀리던 걸 막는다. - * 세월교·배수관은 종전대로 그 행의 노선 프레임을 쓴다. - */ - const boxAxis = section.box ? section.frame.left_xy : null; - const frameOf = (chainageM: number): RouteFrame => { - const frame = routeFrameOf(chainageM); - if (!boxAxis) return frame; - return { ...frame, leftX: boxAxis[0], leftY: boxAxis[1] }; - }; - const inletSide: Side = (section.uphill_side ?? "left") === "left" ? "left" : "right"; - const roleOf = (side: Side): PlanCurve["role"] => (side === inletSide ? "inlet" : "outlet"); - // 노견 끝 편거리는 소유 측점 값을 구간 내내 쓴다 — 구간이 수 m라 확폭 변화는 무시. - const roadEdgeOf = (side: Side): number => - side === "left" ? edges.left.offset_m : edges.right.offset_m; - - const push = ( - source: PlanCurveSource, - side: Side, - loops: Array>, - ): void => { - if (!loops.length) return; - curves.push({ - setChainageM: section.chainage_m, - source, - role: roleOf(side), - side, - planZ, - loops, - ...(boxAxis ? { axisLocked: true } : {}), - }); - }; - - const axis = { - cx: section.center_x, - cy: section.center_y, - leftX: section.frame.left_xy[0], - leftY: section.frame.left_xy[1], - }; - // 벽 자리 후보와 그 근거 영역 — 세트가 끝나면 **머지 경계**만 남겨 한 번에 낸다. - const wallParts = new Map< - Side, - Array<{ points: Array<[number, number, number]>; owner: number }> - >(); - const wallAreas = new Map>>(); - const addArea = (side: Side, loop: ReadonlyArray<[number, number, number]>): number => { - const bucket = wallAreas.get(side) ?? []; - bucket.push(loop.map((point) => [point[0], point[1]] as [number, number])); - wallAreas.set(side, bucket); - return bucket.length - 1; - }; - const addWall = (side: Side, points: Array<[number, number, number]>, owner: number): void => { - const bucket = wallParts.get(side) ?? []; - bucket.push({ points, owner }); - wallParts.set(side, bucket); - }; - (["left", "right"] as const).forEach((side) => { - push("structure", side, structureFootprintLoops(set, side, roadEdgeOf(side), planZ, axis)); - // 날개벽만 — 성토 끝선까지 이어진 사다리꼴(2026-08-26 사용자). 성토 끝선은 - // 노선 전 구간 폴리라인이라 세트 근처만 잘라 넘긴다 — 안 자르면 헤어핀 - // 건너편 성토선에 광선이 얻어걸린다(패치 리본이 겪은 것과 같은 함정). - const toe = nearToe(outline, side, section.chainage_m); - const wing = wingBoxLoops(set, side, planZ, axis, toe); - push("wing-box", side, wing.loops); - // 날개 사다리꼴의 벽 자리 — 성토 끝선 변은 빠져 있다(2026-08-27 사용자). - wing.loops.forEach((loop, index) => { - const owner = addArea(side, loop); - const line = wing.walls[index]; - if (line) addWall(side, line, owner); - }); - }); - - for (const silhouette of structureSilhouettes(section)) { - const side = silhouette.side; - const sign = side === "left" ? 1 : -1; - const roadEdge = roadEdgeOf(side); - // 변형 실루엣 — [노견 확장끝 → 실루엣이 그 행 지반과 만나는 자리]. - // 실루엣은 소유 측점 단면 한 장이라 그대로 밀면 구간 내내 같은 폭이 된다 - // (2026-08-26 사용자 ②) — 행마다 지반선에 트림해 실제 발끝을 따라가게 한다. - // 서피스는 여전히 안 건드린다 — 표기 커브만 바뀐다. - const inner = silhouette.widenTo?.offset_m ?? roadEdge; - const from = section.chainage_m - silhouette.span.beforeM; - const to = section.chainage_m + silhouette.span.afterM; - const stationGround = groundInterpolator(section.samples); - // "auto" = 실루엣 가운데가 지반보다 높으면 성토, 낮으면 절토(패치 리본과 같은 판정). - let kind: "cut" | "fill" = silhouette.kind === "auto" ? "fill" : silhouette.kind; - if (silhouette.kind === "auto") { - const mid = silhouette.points[Math.floor(silhouette.points.length / 2)]; - if (stationGround && mid.elevation_m < stationGround(mid.offset_m) - 0.05) kind = "cut"; - } - const baseZ = frameOf(section.chainage_m).designZ; - const steps: number[] = []; - for (let atM = from; atM < to; atM += STEP_M) steps.push(atM); - steps.push(to); - const deformedRows = steps.map((atM): [PXY, PXY] | null => { - const frame = frameOf(atM); - const ground = groundAt(atM); - if (!ground) return null; - // 종단 경사만큼 실루엣을 통째로 올리고 내린다 — 측점 단면은 그 측점 계획고 기준이다. - const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; - const placed = silhouette.points.map((point) => ({ - offset_m: point.offset_m, - elevation_m: point.elevation_m + shift, - })); - // **구조물에서 끝나는 실루엣**(BOX암거 — 구체 최상단 모서리)은 지반까지 - // 연장하지 않는다. 실루엣이 끝나는 자리가 곧 변형 성토면이 끝나는 자리이고, - // 그 바깥은 구조물 솔리드와 **원지반** 몫이다(2026-08-27 사용자 확정). - // 연장하면 변형 성토선이 원본 성토선과 겹쳐 원지반 재노출 공식 - // `원본 − (구조물 ∪ 변형)`이 공집합이 된다(`_Corridor_Clip.ts`). - let outer: number; - if (silhouette.endsAtStructure) { - outer = placed[placed.length - 1].offset_m; - } else { - const trimmed = trimSlopeToGround(placed, kind, ground); - if (!trimmed) return null; - outer = trimmed[trimmed.length - 1].offset_m; - } - if ((outer - inner) * sign <= MIN_WIDTH_M) return null; - return [at(frame, inner), at(frame, outer)]; - }); - push("slope-deformed", side, stripLoops(deformedRows, planZ)); - // 원본 성토·절토선 — [면 안쪽 끝 → 코리도 바깥 윤곽(catch)]. 구조물이 없다고 본 면이다. - // 안쪽 끝은 **그 행 절·성토면이 실제로 시작하는 자리**를 쓴다 — 소유 측점 노견 - // 편거리를 그대로 쓰면 노폭 연장·측구를 못 따라가 커브가 면 밖(도로 위)에 얹힌다 - // (2026-08-26 사용자 지적. 275.71 좌측 실측: 노견 확장 4m만큼 안쪽으로 들어가 있었다). - const rows: Array<[PXY, PXY] | null> = []; - // BOX암거는 가로 방향이 고정이라 그 행의 윤곽 점을 그대로 쓰면 방향이 틀어진다. - // 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 — - // 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자). - const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : null; - /** - * BOX암거는 **안쪽 끝도** 박스 축 위여야 한다(2026-08-26 사용자: 평면 스케치의 - * 횡단 방향은 박스가 놓인 **전역 단방향**이고 노선 흐름을 타면 안 된다). - * 바깥 끝처럼, 고정 축으로 쏜 선이 **성토면 안쪽 끝선**과 만나는 자리를 쓴다 — - * 한쪽만 축을 맞추면 가로대가 행을 가로질러 비스듬해진다. - */ - const boxInner = boxAxis - ? outline.chainages - .filter((chainageM) => Math.abs(chainageM - section.chainage_m) <= TOE_WINDOW_M) - .map((chainageM) => { - const routeFrame = routeFrameOf(chainageM); - const edge = at(routeFrame, roadEdge); - const start = surface.innerAt(chainageM, side, edge.x, edge.y) ?? edge; - return [start.x, start.y] as [number, number]; - }) - : null; - /** 같은 행을 절·성토면 표고에 얹은 것 — 빨간 투영선(2026-08-26 사용자). */ - const projected: Array<[[number, number, number], [number, number, number]] | null> = []; - outline.chainages.forEach((chainageM, index) => { - if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return; - const frame = frameOf(chainageM); - const edgeXY = at(frame, roadEdge); - const surfaceStart = boxInner - ? castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxInner) - : surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y); - let outerXY: PXY; - if (boxToe) { - const hit = castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxToe); - if (!hit) { - rows.push(null); - projected.push(null); - return; - } - outerXY = { x: hit.x, y: hit.y }; - } else { - const [ox, oy] = side === "left" ? outline.left[index] : outline.right[index]; - outerXY = { x: ox, y: oy }; - } - // 면 시작점이 바깥 끝과 붙어 버리는 행(그 측 비탈이 거의 없는 자리)에서는 - // 노견 편거리로 되돌린다 — 안 그러면 그 측 커브가 통째로 사라진다. - const innerXY = surfaceStart && apart(surfaceStart, outerXY) ? surfaceStart : edgeXY; - const keep = apart(innerXY, outerXY); - rows.push(keep ? [innerXY, outerXY] : null); - // 서피스 표고를 못 읽는 자리(밑에 절·성토 리본이 없는 행)는 선을 끊는다 — - // 없는 면에 억지로 붙이지 않는다. - const innerZ = keep ? surface.zAt(chainageM, side, innerXY.x, innerXY.y) : null; - const outerZ = keep ? surface.zAt(chainageM, side, outerXY.x, outerXY.y) : null; - projected.push( - innerZ === null || outerZ === null - ? null - : [ - [innerXY.x, innerXY.y, innerZ + PROJECT_LIFT_M], - [outerXY.x, outerXY.y, outerZ + PROJECT_LIFT_M], - ], - ); - }); - push("slope-original", side, stripLoops(rows, planZ)); - const stripLoopsXY = projectedLoops(projected); - push("slope-projected", side, stripLoopsXY); - // 측벽이 설 자리 = 스트립의 **양 끝 횡단선**뿐. 긴 변(노견선·성토 끝선)은 뺀다 - // (거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). - const spans = projected.filter( - (pair): pair is [[number, number, number], [number, number, number]] => pair !== null, - ); - if (spans.length >= 2 && stripLoopsXY.length) { - const owner = addArea(side, stripLoopsXY[0]); - addWall(side, [spans[0][0], spans[0][1]], owner); - addWall(side, [spans[spans.length - 1][0], spans[spans.length - 1][1]], owner); - } - } - // 본 구조물 영역 ∪ 날개 영역의 **경계만** 벽으로 낸다 — 다른 영역 안에 잠긴 - // 구간은 속살이라 버린다(2026-08-27 사용자). - wallParts.forEach((parts, side) => { - push("cut-wall", side, trimToUnionBoundary(parts, wallAreas.get(side) ?? [])); - }); - } - return curves; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts deleted file mode 100644 index a53604928..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ /dev/null @@ -1,348 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Plan_Footprint.ts - * 구조물의 **탑뷰 풋프린트 외곽 고리**(2026-08-26 사용자 재구현 지시). - * - * 기하 합집합(옛 `_Corridor_Union.ts`)을 쓰지 않는다 — 겹친 스트립에서 외곽선이 - * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). - * - * 대신 구조물이 **스윕 솔리드**라는 성질을 그대로 쓴다: - * ① 부재마다 **따로** 낸다 — 겹친 부재를 하나로 머지하지 않는다 - * (2026-08-26 사용자: "구조물은 머지하지 말고 표현할 것, 나중에 한번에"). - * ② 링마다 그 링 폴리곤이 차지하는 편거리 창을 도로 밖으로 자른다. - * ③ 창이 있는 링이 이어지는 동안 한 고리 — 창이 없는 링에서 끊는다. - * - * 기하 불리언이 없어 퇴화(같은 사각형 겹침·포함 관계·맞닿은 변)가 없다. - * ========================================================================== */ - -import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; - -/** 안팎 폭이 이만큼은 돼야 커브를 만든다(m). */ -const MIN_WIDTH_M = 0.05; - -/** 폭 판정 여유(m). */ -const EPS_M = 1e-6; - -type Side = "left" | "right"; - -/** 편거리 구간 — 항상 `lo <= hi`(부호 있는 편거리 그대로). */ -type Span = [number, number]; - -/** 링 폴리곤이 그 링에서 차지하는 편거리 폭. */ -function polygonSpan(polygon: ReadonlyArray | undefined): Span | null { - if (!polygon || !polygon.length) return null; - let lo = Infinity; - let hi = -Infinity; - for (const [offset] of polygon) { - if (offset < lo) lo = offset; - if (offset > hi) hi = offset; - } - return hi - lo > EPS_M ? [lo, hi] : null; -} - -/** 도로부(노견 안쪽)를 잘라낸 나머지 — 폭이 없으면 null. */ -function clipOutsideRoad(span: Span, side: Side, roadEdgeOffset: number): Span | null { - const lo = side === "left" ? Math.max(span[0], roadEdgeOffset) : span[0]; - const hi = side === "left" ? span[1] : Math.min(span[1], roadEdgeOffset); - return hi - lo > MIN_WIDTH_M ? [lo, hi] : null; -} - -/** 편거리를 실좌표로 — 링 프레임의 좌향 벡터를 탄다. */ -function at(frame: StructureFrame, offset: number): [number, number] { - return [frame.cx + frame.leftX * offset, frame.cy + frame.leftY * offset]; -} - -/** 링 한 줄 — 그 링의 프레임과 살아남은 편거리 창. */ -interface Row { - frame: StructureFrame; - span: Span; -} - -/** 이어진 링 줄을 닫힌 고리로 — 바깥 가장자리를 앞으로, 안쪽을 뒤로 이어 닫는다. */ -function runLoop( - run: ReadonlyArray, - side: Side, - planZ: number, -): Array<[number, number, number]> | null { - if (run.length < 2) return null; - const outerOf = (span: Span): number => (side === "left" ? span[1] : span[0]); - const innerOf = (span: Span): number => (side === "left" ? span[0] : span[1]); - const loop: Array<[number, number, number]> = []; - for (const row of run) { - const [x, y] = at(row.frame, outerOf(row.span)); - loop.push([x, y, planZ]); - } - for (let i = run.length - 1; i >= 0; i -= 1) { - const [x, y] = at(run[i].frame, innerOf(run[i].span)); - loop.push([x, y, planZ]); - } - loop.push(loop[0]); - return loop; -} - -/** 측점 프레임(중심 + 좌향 단위벡터) — 날개벽이 어느 측인지 가리는 기준이다. */ -export interface StationAxis { - cx: number; - cy: number; - leftX: number; - leftY: number; -} - -/** 그 자리의 노선 편거리(+ = 좌측) — 측점 프레임 기준. */ -function routeOffset(axis: StationAxis, x: number, y: number): number { - return (x - axis.cx) * axis.leftX + (y - axis.cy) * axis.leftY; -} - -/** 날개벽이 붙은 측 — 링 가운데의 노선 편거리 부호로 가린다. */ -function wingSide(rings: ReadonlyArray, axis: StationAxis): Side { - const middle = rings[Math.floor(rings.length / 2)]; - return routeOffset(axis, middle.cx, middle.cy) >= 0 ? "left" : "right"; -} - -/** 링 한 줄 + 그 링의 **축 방향 좌표** — 부재끼리 링 자리를 맞추는 기준이다. */ -interface AxisRow extends Row { - t: number; -} - -/** 고정축을 따라 잰 좌표 — 좌향 벡터에 수직인 방향. 부호는 일관되기만 하면 된다. */ -function axisCoord(axis: StationAxis, cx: number, cy: number): number { - return (cx - axis.cx) * axis.leftY - (cy - axis.cy) * axis.leftX; -} - -/** 그 부재의 축 좌표 `t`에서의 편거리 창 — 링 사이는 선형 보간, 범위 밖은 null. */ -function spanAt(rows: ReadonlyArray, t: number): Span | null { - if (t < rows[0].t - EPS_M || t > rows[rows.length - 1].t + EPS_M) return null; - for (let i = 0; i < rows.length - 1; i += 1) { - const a = rows[i]; - const b = rows[i + 1]; - if (t < a.t - EPS_M || t > b.t + EPS_M) continue; - const width = b.t - a.t; - const k = width > EPS_M ? (t - a.t) / width : 0; - return [a.span[0] + (b.span[0] - a.span[0]) * k, a.span[1] + (b.span[1] - a.span[1]) * k]; - } - return rows[rows.length - 1].span; -} - -/** - * **아령 외곽선**(2026-08-27 사용자) — `planOutline` 표식이 붙은 부재(BOX암거 상판 + - * 에이프런)를 하나로 합친 탑뷰 경계 고리. 표식이 없으면 `null`. - * - * 사용자 원문: "박스암거의 경우에는 바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 - * 선 성분을 가져가 평면 투영하면 경계가 되고 이걸 경계로 사용하면 가능할 것 같은데?" - * - * 기하 불리언은 여전히 쓰지 않는다(옛 `unionOutline`이 겹친 스트립에서 무너진 이유는 - * 이 파일 머리말 참조). BOX 부재는 전부 **같은 고정축 위 평행 링**이라, 링 자리마다 - * 각 부재의 편거리 창을 재서 **제일 바깥·제일 안쪽**을 취하면 그게 곧 외곽선이다. - * 퇴화(겹침·포함·맞닿은 변)가 생길 여지가 없다. - */ -function outlineLoop( - set: ReadonlyArray, - side: Side, - roadEdgeOffset: number, - planZ: number, - axis: StationAxis, -): Array<[number, number, number]> | null { - const members: AxisRow[][] = []; - for (const structure of set) { - if (!structure.planOutline) continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - const rows: AxisRow[] = []; - rings.forEach((frame, index) => { - const span = polygonSpan(structure.polygons?.[index] ?? structure.polygon); - if (span) rows.push({ frame, span, t: axisCoord(axis, frame.cx, frame.cy) }); - }); - if (rows.length >= 2) members.push(rows.sort((a, b) => a.t - b.t)); - } - if (!members.length) return null; - // 링 자리는 부재마다 다르다(상판 ±halfSpan·±innerHalf, 에이프런 ±reach·±halfSpan) — - // 전부 모아 그 자리마다 창을 합친다. 프레임은 그 자리에 실제로 링이 있는 부재 것을 - // 그대로 쓴다(전부 같은 축이라 프레임 보간이 필요 없다). - const stops = new Map(); - for (const rows of members) { - for (const row of rows) { - const key = Math.round(row.t / EPS_M) * EPS_M; - if (!stops.has(key)) stops.set(key, row.frame); - } - } - const rows: Row[] = []; - for (const t of [...stops.keys()].sort((a, b) => a - b)) { - let lo = Infinity; - let hi = -Infinity; - for (const member of members) { - const span = spanAt(member, t); - if (!span) continue; - if (span[0] < lo) lo = span[0]; - if (span[1] > hi) hi = span[1]; - } - if (hi - lo <= EPS_M) continue; - const clipped = clipOutsideRoad([lo, hi], side, roadEdgeOffset); - const frame = stops.get(t); - if (clipped && frame) rows.push({ frame, span: clipped }); - } - return runLoop(rows, side, planZ); -} - -/** - * 구조물 세트의 그 측 탑뷰 외곽 고리들 — **부재마다 따로**, 도로부(노견 안쪽)는 뺀다. - * - * 배관은 뺀다 — 다른 부재가 감싸고 있어 투영 면적에 안 들어간다(2026-08-26 사용자). - * - * 날개벽(`wing`)은 링이 노선이 아니라 **날개 축**을 타 링 폴리곤의 편거리가 노선 - * 편거리가 아니라 **패널 두께**다. 도로부 클립을 그대로 걸면 전부 죽으므로 클립을 - * 생략하고, 대신 링 위치로 측을 가린다 — 날개는 애초에 노견 밖에서 시작한다. - */ -export function structureFootprintLoops( - set: ReadonlyArray, - side: Side, - roadEdgeOffset: number, - planZ: number, - axis: StationAxis, -): Array> { - // 아령 외곽선을 낼 수 있는 세트(BOX암거)는 **그 한 줄만** 낸다 — 부재별 고리를 - // 함께 내면 날개 패널 슬리버가 다시 섞여 지형 재노출에 구멍을 만든다. - // 저판·측벽은 상판 폭 안, 날개는 에이프런 안이라 외곽선이 전부 품는다. - const outline = outlineLoop(set, side, roadEdgeOffset, planZ, axis); - if (outline) return [outline]; - const loops: Array> = []; - for (const structure of set) { - if (structure.kind === "pipe") continue; - const rings = structure.rings; - if (!rings || rings.length < 2) continue; - if (structure.wing && wingSide(rings, axis) !== side) continue; - let run: Row[] = []; - const flush = (): void => { - const loop = runLoop(run, side, planZ); - if (loop) loops.push(loop); - run = []; - }; - rings.forEach((frame, index) => { - const polygon = structure.polygons?.[index] ?? structure.polygon; - const span = polygonSpan(polygon); - const clipped = structure.wing - ? span - : span - ? clipOutsideRoad(span, side, roadEdgeOffset) - : null; - if (clipped) run.push({ frame, span: clipped }); - else flush(); - }); - flush(); - } - return loops; -} - -/** 광선이 성토 끝선(폴리라인)과 만난 자리 — 교점과 그 앞 마디 번호. */ -export interface ToeHit { - x: number; - y: number; - segment: number; -} - -/** - * 이보다 멀리서 만난 자리는 안 쓴다(m) — 헤어핀 건너편 성토선에 얻어걸리는 걸 막는다. - * 실측상 날개 끝↔성토 끝선은 1.6~2.9m다. 넉넉히 잡아도 이 정도면 충분하다. - */ -const RAY_LIMIT_M = 15; - -/** - * 원점을 지나 방향 `(dx, dy)`를 타는 **직선**이 성토 끝선과 만나는 가장 가까운 자리. - * - * 광선(바깥쪽만)이 아니라 직선인 이유 — 세월교 날개는 끝이 성토 끝선 **밖으로** - * 나간다(2026-08-26 실측: 날개 끝 편거리 4.16, 그 자리 성토 끝선 2.60). 바깥으로만 - * 쏘면 영영 안 만나 박스가 통째로 빠진다. 안쪽에서 만나도 "성토 끝선과 만나게"라는 - * 규칙은 그대로 성립한다. - */ -export function castToToe( - ox: number, - oy: number, - dx: number, - dy: number, - toe: ReadonlyArray, -): ToeHit | null { - let best: ToeHit | null = null; - let bestT = Infinity; - for (let i = 0; i < toe.length - 1; i += 1) { - const [ax, ay] = toe[i]; - const [bx, by] = toe[i + 1]; - const ex = bx - ax; - const ey = by - ay; - const denominator = dx * ey - dy * ex; - if (Math.abs(denominator) < 1e-12) continue; - const t = ((ax - ox) * ey - (ay - oy) * ex) / denominator; - const u = ((ax - ox) * dy - (ay - oy) * dx) / denominator; - const reach = Math.abs(t); - if (reach < 1e-6 || reach > RAY_LIMIT_M || u < -1e-9 || u > 1 + 1e-9 || reach >= bestT) { - continue; - } - bestT = reach; - best = { x: ox + dx * t, y: oy + dy * t, segment: i }; - } - return best; -} - -/** - * 날개벽 **성토끝 박스**(2026-08-26 사용자 협의 확정) — 다른 구조물과 달리 날개벽은 - * 성토 바깥까지 이어진 영역을 하나 더 그린다. - * - * · 밑변 = 날개벽 **시작 모서리 → 끝 모서리**. - * · 양 옆선 = 그 두 점에서 **구조물 축과 평행**하게 바깥으로. 축은 측점 좌향 - * 벡터(노선 수직)의 **고정 직선**이다 — 노선 곡률을 따라 각도가 바뀌지 않는다. - * · 바깥 변 = 두 교점 사이의 **원본 성토면 최하단 끝선** 그대로. - */ -export function wingBoxLoops( - set: ReadonlyArray, - side: Side, - planZ: number, - axis: StationAxis, - toe: ReadonlyArray, -): { - loops: Array>; - /** 측벽이 설 자리 — [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점]. 성토 끝선 구간은 - * 뺀다(거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). */ - walls: Array>; -} { - if (toe.length < 2) return { loops: [], walls: [] }; - const sign = side === "left" ? 1 : -1; - const loops: Array> = []; - const walls: Array> = []; - for (const structure of set) { - const rings = structure.rings; - // 날개벽(revet)만 — 세월교 바닥 에이프런(basin)도 `wing` 표식을 달지만 박스는 없다. - if (!structure.wing || structure.kind !== "revet" || !rings || rings.length < 2) continue; - if (wingSide(rings, axis) !== side) continue; - // 옆선 방향 — 세월교는 그 날개가 붙은 자리의 노선 좌향(구조물이 실어 보낸다), - // BOX암거는 기준 측점의 좌향 그대로다(2026-08-26 사용자: "박스 암거는 유지"). - const [ax, ay] = structure.wingAxis ?? [axis.leftX, axis.leftY]; - const dx = ax * sign; - const dy = ay * sign; - const start = rings[0]; - const end = rings[rings.length - 1]; - const startHit = castToToe(start.cx, start.cy, dx, dy, toe); - const endHit = castToToe(end.cx, end.cy, dx, dy, toe); - if (!startHit || !endHit) continue; - const loop: Array<[number, number, number]> = [ - [start.cx, start.cy, planZ], - [end.cx, end.cy, planZ], - [endHit.x, endHit.y, planZ], - ]; - // 두 교점 사이 성토 끝선을 그대로 탄다 — 마디 번호 순서대로 훑는다. - const step = endHit.segment <= startHit.segment ? 1 : -1; - for ( - let i = endHit.segment + (step > 0 ? 1 : 0); - step > 0 ? i <= startHit.segment : i > startHit.segment; - i += step - ) { - loop.push([toe[i][0], toe[i][1], planZ]); - } - loop.push([startHit.x, startHit.y, planZ]); - loop.push(loop[0]); - loops.push(loop); - walls.push([ - [startHit.x, startHit.y, planZ], - [start.cx, start.cy, planZ], - [end.cx, end.cy, planZ], - [endHit.x, endHit.y, planZ], - ]); - } - return { loops, walls }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts deleted file mode 100644 index e7562c5f2..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Region.ts +++ /dev/null @@ -1,404 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Region.ts - * 씬 수평면(x,z) 위의 **평면 영역**(사각 셀 격자 + 경계 선분) — 절단기 - * (`_Corridor_Clip.ts`)가 "이 안쪽을 도려내라"고 넘기는 자료다. 700줄 제한으로 - * `_Corridor_Clip.ts`에서 분리했다(2026-08-25). - * - * 영역은 `CorridorStrip`(코리도 외곽 — 좌우 catch line이 만드는 띠) 하나다. 지형에서 - * 절·성토 점유분을 도려낼 때 쓴다. 구조물 절단은 여기 없다 — Build가 행 단면을 - * 파라메트릭 경계(`_Corridor_Carve.ts`)로 미리 트림한다(2026-08-25). - * - * 셀(사각형) 단위 내부 판정이라 급곡선에서 외곽 폴리곤이 자기교차해도 견고하다. - * ========================================================================== */ - -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; - -export interface P2 { - x: number; - z: number; -} - -const EPS = 1e-9; - -/** 구조물 상단과 서피스가 "같은 높이"로 볼 여유(m) — 딱 붙는 자리의 z-fight 방지. */ -const COVER_TOLERANCE_M = 0.02; - -export function cross2(ax: number, az: number, bx: number, bz: number): number { - return ax * bz - az * bx; -} - -export 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); -} - -export 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; -} - -/** 경계 선분 하나 — AABB를 함께 들고 있어 격자 후보 판정이 싸다. */ -export interface RegionSegment { - p: P2; - q: P2; - minX: number; - maxX: number; - minZ: number; - maxZ: number; -} - -/** 절단기가 보는 평면 영역의 최소 규약. */ -export interface PlanRegion { - readonly minX: number; - readonly maxX: number; - readonly minZ: number; - readonly maxZ: number; - /** - * 이 평면 점이 영역 안인가. `sceneY`를 주면 **그 표고까지 덮는 자리**만 안으로 본다 - * — 구조물이 성토면 **아래**로 지나가면(복토 밑 구체 같은) 성토면을 지우면 안 된다 - * (2026-08-25 사용자: 탑뷰에서 "노출되어야 하는" 면적만 투영한다). - */ - contains(p: P2, sceneY?: number): boolean; - segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[]; -} - -/** - * 사각 셀 + 경계 선분을 균일격자로 색인한 평면 영역. - * - * 격자가 없으면 지형 삼각형 수 × 셀 수가 되어 편집 반복 시 초 단위로 멎는다 - * (2026-08-23 프리즈 보고). 격자로 후보만 본다. - */ -export class CellRegion implements PlanRegion { - protected cells: Array<{ - a: P2; - b: P2; - c: P2; - d: P2; - minX: number; - maxX: number; - minZ: number; - maxZ: number; - }> = []; - /** 셀마다의 상단 씬 Y — 이 위로는 못 덮는다. 표고 개념이 없으면 Infinity. */ - protected cellTops: number[] = []; - protected segments: RegionSegment[] = []; - minX = Infinity; - maxX = -Infinity; - minZ = Infinity; - maxZ = -Infinity; - private grid = 1; - private gridCols = 1; - private gridRows = 1; - private cellBuckets: number[][] = []; - private segmentBuckets: number[][] = []; - - /** 셀(사각형) 하나 추가 — 꼭짓점은 a→b→c→d 한 바퀴 순서, `top`은 상단 씬 Y다. */ - protected addCell(a: P2, b: P2, c: P2, d: P2, top = Infinity): void { - [a, b, c, d].forEach((point) => { - if (point.x < this.minX) this.minX = point.x; - if (point.x > this.maxX) this.maxX = point.x; - if (point.z < this.minZ) this.minZ = point.z; - if (point.z > this.maxZ) this.maxZ = point.z; - }); - this.cellTops.push(top); - 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), - }); - } - - protected addSegment(p: P2, q: P2): void { - // 선분만 있는 영역(고리 영역)도 경계 상자를 갖도록 여기서도 넓힌다 — 셀 영역은 - // 선분이 셀 위에 놓이므로 값이 달라지지 않는다. - [p, q].forEach((point) => { - if (point.x < this.minX) this.minX = point.x; - if (point.x > this.maxX) this.maxX = point.x; - if (point.z < this.minZ) this.minZ = point.z; - if (point.z > this.maxZ) this.maxZ = point.z; - }); - 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), - }); - } - - /** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 셀 한 변). */ - protected buildIndex(): void { - // 셀이 없으면 선분 크기로 격자를 잡는다 — 고리 영역(`PlanLoopRegion`)은 셀 없이 - // 경계 선분만 들고 있다(2026-08-27). - const sources: Array<{ minX: number; maxX: number; minZ: number; maxZ: number }> = this.cells - .length - ? this.cells - : this.segments; - if (!sources.length) return; - const meanCell = - sources.reduce((sum, item) => sum + (item.maxX - item.minX) + (item.maxZ - item.minZ), 0) / - sources.length; - this.grid = Math.max(2, meanCell); - this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1); - this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1); - this.cellBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); - this.segmentBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); - const put = ( - buckets: number[][], - index: number, - minX: number, - maxX: number, - minZ: number, - maxZ: number, - ): void => { - const c0 = this.colOf(minX); - const c1 = this.colOf(maxX); - const r0 = this.rowOf(minZ); - const r1 = this.rowOf(maxZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) buckets[r * this.gridCols + c].push(index); - } - }; - this.cells.forEach((cell, i) => - put(this.cellBuckets, i, cell.minX, cell.maxX, cell.minZ, cell.maxZ), - ); - this.segments.forEach((s, i) => put(this.segmentBuckets, i, s.minX, s.maxX, s.minZ, s.maxZ)); - } - - private colOf(x: number): number { - return Math.min(this.gridCols - 1, Math.max(0, Math.floor((x - this.minX) / this.grid))); - } - - private rowOf(z: number): number { - return Math.min(this.gridRows - 1, Math.max(0, Math.floor((z - this.minZ) / this.grid))); - } - - /** 셀이 하나도 없으면 아무것도 안 지운다(빈 영역). */ - get isEmpty(): boolean { - return this.cells.length === 0; - } - - contains(p: P2, sceneY?: number): boolean { - if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false; - const bucket = this.cellBuckets[this.rowOf(p.z) * this.gridCols + this.colOf(p.x)]; - if (!bucket) return false; - for (const index of bucket) { - const cell = this.cells[index]; - if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue; - // 서피스가 셀 상단보다 위에 있으면 그 자리는 구조물에 가려지지 않는다 — 남긴다. - // 두 면이 딱 붙는 자리는 z-fight가 나므로 여유(COVER_TOLERANCE_M)만큼 봐 준다. - if (sceneY !== undefined && sceneY > this.cellTops[index] + COVER_TOLERANCE_M) 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): RegionSegment[] { - if (!this.segmentBuckets.length) return []; - const seen = new Set(); - const result: RegionSegment[] = []; - const c0 = this.colOf(minX); - const c1 = this.colOf(maxX); - const r0 = this.rowOf(minZ); - const r1 = this.rowOf(maxZ); - for (let r = r0; r <= r1; r += 1) { - for (let c = c0; c <= c1; c += 1) { - for (const index of this.segmentBuckets[r * this.gridCols + c]) { - if (seen.has(index)) continue; - seen.add(index); - const s = this.segments[index]; - if (s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ) result.push(s); - } - } - } - return result; - } -} - -/** 모델 좌표(동·북) → 씬 수평면(x, z) 변환기 — 뷰어 공통 규약. */ -export function scenePlanMapper(bounds: ModelBounds): (mx: number, my: number) => P2 { - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - return (mx: number, my: number): P2 => ({ x: mx - cx, z: -(my - cy) }); -} - -/** 코리도 스트립 — 좌우 catch line 사이를 프레임 사각형으로 담는다. */ -export class CorridorStrip extends CellRegion { - constructor(build: CorridorBuildResult, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - const left = build.outline.left.map(([mx, my]) => toScene(mx, my)); - const right = build.outline.right.map(([mx, my]) => toScene(mx, my)); - const count = Math.min(left.length, right.length); - for (let i = 0; i < count - 1; i += 1) { - this.addCell(left[i], right[i], right[i + 1], left[i + 1]); - } - for (let i = 0; i < count - 1; i += 1) { - this.addSegment(left[i], left[i + 1]); - this.addSegment(right[i], right[i + 1]); - } - if (count > 0) { - this.addSegment(left[0], right[0]); - this.addSegment(left[count - 1], right[count - 1]); - } - this.buildIndex(); - } -} - -/** - * 닫힌 고리들이 덮는 평면 영역(2026-08-27 사용자 ③) — 구조물 풋프린트·날개 사다리꼴처럼 - * 격자에 안 실리는 임의 다각형을 담는다. 오목해도 옳도록 내부 판정은 **광선 투사**다 - * (부채꼴 삼각분할은 오목 고리에서 틀린다). 경계 선분은 상속받은 격자로 색인해 - * 절단기가 삼각형마다 후보만 보게 한다. - */ -/** 고리 마디 하나로 볼 최소 길이(m)·꺾임 여유(m) — 절단선 수를 줄이는 단순화 기준. */ -const LOOP_MIN_SEGMENT_M = 0.25; -const LOOP_COLLINEAR_M = 0.02; - -/** - * 고리를 **절단선 수 기준으로** 단순화한다(2026-08-27). 경계 선분 하나가 무한직선 하나로 - * 삼각형을 자르므로, 구조물 풋프린트처럼 점이 촘촘한 고리를 그대로 쓰면 한 삼각형이 - * 수십 번 쪼개져 조각이 폭증한다(실측: 구멍 134㎡ 자리에 7,948조각). 짧은 마디와 거의 - * 일직선인 점을 걷어내면 형상은 그대로 두고 절단선만 준다. - */ -function simplifyLoop(points: ReadonlyArray): P2[] { - const kept: P2[] = []; - for (const point of points) { - const last = kept[kept.length - 1]; - if (last && Math.hypot(point.x - last.x, point.z - last.z) < LOOP_MIN_SEGMENT_M) continue; - kept.push(point); - } - // 시작·끝이 겹치면 하나로 — 광선 투사는 자동으로 닫는다. - if (kept.length > 1) { - const first = kept[0]; - const last = kept[kept.length - 1]; - if (Math.hypot(first.x - last.x, first.z - last.z) < LOOP_MIN_SEGMENT_M) kept.pop(); - } - if (kept.length < 4) return kept; - const thinned: P2[] = []; - for (let i = 0; i < kept.length; i += 1) { - const previous = thinned[thinned.length - 1] ?? kept[kept.length - 1]; - const point = kept[i]; - const next = kept[(i + 1) % kept.length]; - // 앞뒤를 잇는 직선에서 이 점이 거의 안 벗어나면 뺀다(꺾임이 없다). - const dx = next.x - previous.x; - const dz = next.z - previous.z; - const length = Math.hypot(dx, dz); - const offset = - length < 1e-9 - ? Math.hypot(point.x - previous.x, point.z - previous.z) - : Math.abs(dx * (point.z - previous.z) - dz * (point.x - previous.x)) / length; - if (offset > LOOP_COLLINEAR_M) thinned.push(point); - } - return thinned.length >= 3 ? thinned : kept; -} - -export class PlanLoopRegion extends CellRegion { - private loops: Array<{ points: P2[]; minX: number; maxX: number; minZ: number; maxZ: number }> = - []; - - constructor(loops: ReadonlyArray>, bounds: ModelBounds) { - super(); - const toScene = scenePlanMapper(bounds); - for (const loop of loops) { - if (loop.length < 3) continue; - const points = simplifyLoop(loop.map(([mx, my]) => toScene(mx, my))); - if (points.length < 3) continue; - let minX = Infinity; - let maxX = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - points.forEach((point) => { - if (point.x < minX) minX = point.x; - if (point.x > maxX) maxX = point.x; - if (point.z < minZ) minZ = point.z; - if (point.z > maxZ) maxZ = point.z; - }); - this.loops.push({ points, minX, maxX, minZ, maxZ }); - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - this.addSegment(points[j], points[i]); - } - } - this.buildIndex(); - } - - override get isEmpty(): boolean { - return this.loops.length === 0; - } - - override contains(p: P2): boolean { - if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false; - for (const loop of this.loops) { - if (p.x < loop.minX || p.x > loop.maxX || p.z < loop.minZ || p.z > loop.maxZ) continue; - const points = loop.points; - let hit = false; - for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { - const a = points[i]; - const b = points[j]; - if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) { - hit = !hit; - } - } - if (hit) return true; - } - return false; - } -} - -/** - * **차집합 영역**(2026-08-27 사용자 ③) — 바탕에서 구멍을 뺀 자리만 "안"으로 본다. - * 구조물이 안착한 뒤 그 평면 영역에서 원지반이 다시 드러나야 하므로, 지형을 도려낼 때 - * 코리도 스트립에서 구조물·날개 영역을 뺀다. 경계 선분은 **양쪽 합집합**이라 구멍 - * 가장자리에서도 지형이 깨끗하게 재절단된다. - */ -export class SubtractedRegion implements PlanRegion { - constructor( - private readonly base: PlanRegion, - private readonly holes: ReadonlyArray, - ) {} - - get minX(): number { - return this.base.minX; - } - - get maxX(): number { - return this.base.maxX; - } - - get minZ(): number { - return this.base.minZ; - } - - get maxZ(): number { - return this.base.maxZ; - } - - contains(p: P2, sceneY?: number): boolean { - if (!this.base.contains(p, sceneY)) return false; - // 구멍은 표고를 따지지 않는다 — 그 평면 자리는 원지반이 드러나는 게 규칙이다. - return !this.holes.some((hole) => hole.contains(p)); - } - - segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[] { - const result = this.base.segmentsNear(minX, maxX, minZ, maxZ); - for (const hole of this.holes) result.push(...hole.segmentsNear(minX, maxX, minZ, maxZ)); - return result; - } -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts deleted file mode 100644 index caed3e430..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Skirt.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Skirt.ts - * **변형 성토면(패치 리본) 바깥 끝 스커트** — 2026-08-27 사용자. - * - * 왜: 기본 성토면은 행마다 지반선에 트림되고(`_Corridor_Build.ts` `trimSlopeToGround`) - * 최외곽 정점을 지형 메시에 스냅해(`_UI_Viewer.ts` `snapCorridorEdges`) 지반과 맞물린다. - * 패치 리본은 구조물 측점 단면 한 장을 종단 shift만 얹어 복사한 것이라 지반 트림이 - * 없고, 스냅 대상에서도 빠져 있다(스냅하면 벽 뒷면에서 끝나야 할 끝이 원지반까지 - * 끌려 내려가 4.1m 세로 지느러미가 생겼다 — 2026-08-27 실측). 그래서 구간을 따라 - * 지반이 오르내리면 패치 바깥 끝이 공중에 뜬다. - * - * 방식: 정점은 **하나도 건드리지 않는다**. 바깥 끝 모서리에서 원지반까지 판만 세운다 - * (`_Corridor_Cut.ts`의 절취 측벽과 같은 수법). 지느러미를 막는 두 조건: - * · 마디 양 끝이 **지반보다 위**일 때만 세운다 — 잠긴 자리는 지형이 덮는다. - * · 낙차가 `PATCH_SKIRT_MAX_M`을 넘는 마디는 건너뛴다 — 벽 뒷면·구체 상단에서 - * 끝나는 줄이 통째로 원지반까지 떨어지는 걸 막는다. - * ========================================================================== */ - -import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; - -/** 밑단을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다. - * 절취 측벽(`_Corridor_Cut.ts` `SKIRT_MARGIN_M`)과 같은 값·같은 이유다. */ -const SKIRT_MARGIN_M = 1; - -/** 세울 수 있는 최대 낙차(m) — 이걸 넘으면 지반과의 틈이 아니라 **끝나는 게 정상인 - * 자리**(벽 뒷면·구체 상단)로 본다. 넘는 마디는 건너뛴다(2026-08-27 사용자). */ -const PATCH_SKIRT_MAX_M = 3; - -/** 판을 세울 만한 최소 낙차(m) — 이 아래는 지형이 덮어 보이지 않는다. */ -const SKIRT_MIN_DROP_M = 0.02; - -/** 빗금 한 타일이 덮는 실거리(m) — 성토 리본의 종방향 규칙(2m마다 1타일)과 같다. */ -const HATCH_TILE_M = 2; - -/** - * **지반에서 끝나는 줄**로 볼 허용 낙차(m) — 소유 측점(구간 한가운데) 행에서 잰다. - * - * 왜 필요한가(2026-08-27 사용자 지적): 다단 성토는 `성토 > 구조물 > 성토`라 패치가 - * 여러 줄이고, 안쪽 줄들은 **벽 뒷면·구체 상단에서 끝나는 게 정상**이다. 그 줄에도 - * 판을 세우면 BOX암거 앞면처럼 없던 서피스가 생긴다(실측: 구간 201에서 바깥 끝이 - * 지반 위 1.96~2.2m). B06 실루엣은 소유 측점에서 지반에 트림돼 있으므로, 그 행의 - * 낙차가 이 값 안이면 "지반까지 내려오는 줄", 넘으면 "구조물에서 끝나는 줄"이다. - */ -const PATCH_TOE_TOLERANCE_M = 0.5; - -/** 검증용 요약 — 화면 밖에서 수치로 확인한다(`window.__corridorSkirt`). */ -export interface PatchSkirtStats { - /** 스커트가 붙은 패치 리본 수. */ - ribbons: number; - /** 세운 삼각형 수. */ - triangles: number; - /** 실제로 세운 마디의 최대 낙차(m). */ - maxDropM: number; - /** 낙차 상한을 넘겨 건너뛴 마디 수. */ - skippedTall: number; - /** 지반 아래(또는 붙어 있어) 건너뛴 마디 수. */ - skippedSubmerged: number; - /** 지반 높이를 못 읽어 건너뛴 마디 수. */ - skippedNoGround: number; - /** 리본별 계측 — 어느 열이 실제 바깥인지, 낙차가 어디서 나는지 확인용. */ - detail: Array<{ - side: string; - rows: number; - cols: number; - from: number; - to: number; - /** 첫 행 기준 양 끝 열의 [설계z, 지반z, 낙차]. */ - col0: [number, number, number]; - colLast: [number, number, number]; - /** 소유 측점 행 바깥 끝의 낙차(m)와 그 판정 — 지반에서 끝나는 줄만 스커트 대상. */ - gateDrop: number; - reachesGround: boolean; - triangles: number; - }>; -} - -/** - * 패치 리본 **바깥 끝 모서리**에 스커트를 세운다. - * - * `groundAt`은 모델 좌표 (x, y) 자리의 **원지반 모델 z**를 돌려준다(지형 밖이면 null). - * 보이는 틈은 렌더된 지형 메시와의 틈이므로 호출부가 지형 메시 색인을 넘긴다 — - * 횡단 샘플러 보간값을 쓰면 지반이 두 벌이 되어 또 미세 틈이 난다. - */ -export function buildPatchSkirts( - ribbons: ReadonlyArray, - groundAt: (x: number, y: number) => number | null, -): { walls: CorridorCutWall[]; stats: PatchSkirtStats } { - const walls: CorridorCutWall[] = []; - const stats: PatchSkirtStats = { - ribbons: 0, - triangles: 0, - maxDropM: 0, - skippedTall: 0, - skippedSubmerged: 0, - skippedNoGround: 0, - detail: [], - }; - for (const ribbon of ribbons) { - if (!ribbon.patch || ribbon.kind !== "fill") continue; - if (ribbon.side !== "left" && ribbon.side !== "right") continue; - const cols = ribbon.colCount; - const rows = ribbon.chainages.length; - if (cols < 2 || rows < 2) continue; - // 패치 리본은 단면을 **수집 순서(도로측 → 바깥)** 그대로 담는다 — Build 리본과 - // 달리 우측을 뒤집지 않으므로 좌·우 모두 마지막 열이 바깥 끝이다(`_Carve.ts`). - const outerCol = cols - 1; - const tris: number[] = []; - // 계측용 — 첫 행 양 끝 열의 설계z·지반z·낙차(어느 열이 실제 바깥인지 확인). - const probe = (col: number): [number, number, number] => { - const i = col * 3; - const z = ribbon.positions[i + 2]; - const g = groundAt(ribbon.positions[i], ribbon.positions[i + 1]); - return [z, g ?? NaN, g === null ? NaN : z - g]; - }; - const col0 = probe(0); - const colLast = probe(cols - 1); - // **지반에서 끝나는 줄인가** — 소유 측점(구간 한가운데) 행에서 잰다. 벽 뒷면·구체 - // 상단에서 끝나는 줄이면 여기서 빠진다(2026-08-27 사용자: BOX암거 앞면에 없던 - // 서피스가 생겼다). - const middle = Math.floor(rows / 2); - const gateIndex = (middle * cols + outerCol) * 3; - const gateGround = groundAt(ribbon.positions[gateIndex], ribbon.positions[gateIndex + 1]); - const gateDrop = gateGround === null ? NaN : ribbon.positions[gateIndex + 2] - gateGround; - const reachesGround = gateGround !== null && gateDrop <= PATCH_TOE_TOLERANCE_M; - // UV — u는 모서리를 따라 걸은 실거리, v는 표고. 둘 다 2m마다 한 타일이라 빗금이 - // 성토 리본과 같은 실척으로 이어진다(2026-08-27 사용자: 스커트는 메시 서피스). - const uvs: number[] = []; - let walked = 0; - let previous: { x: number; y: number; top: number; bottom: number } | null = null; - for (let row = 0; reachesGround && row < rows; row += 1) { - const i = (row * cols + outerCol) * 3; - const x = ribbon.positions[i]; - const y = ribbon.positions[i + 1]; - const top = ribbon.positions[i + 2]; - const ground = groundAt(x, y); - const current = - ground === null ? null : { x, y, top, bottom: Math.min(top, ground) - SKIRT_MARGIN_M }; - const dropOf = (point: NonNullable): number => - point.top - (point.bottom + SKIRT_MARGIN_M); - if (previous && current) { - const dropA = dropOf(previous); - const dropB = dropOf(current); - if (dropA < SKIRT_MIN_DROP_M && dropB < SKIRT_MIN_DROP_M) { - stats.skippedSubmerged += 1; - } else if (Math.max(dropA, dropB) > PATCH_SKIRT_MAX_M) { - stats.skippedTall += 1; - } else { - const uPrev = walked / HATCH_TILE_M; - const uCurrent = - (walked + Math.hypot(current.x - previous.x, current.y - previous.y)) / HATCH_TILE_M; - tris.push( - previous.x, - previous.y, - previous.top, - current.x, - current.y, - current.top, - current.x, - current.y, - current.bottom, - ); - uvs.push( - uPrev, - previous.top / HATCH_TILE_M, - uCurrent, - current.top / HATCH_TILE_M, - uCurrent, - current.bottom / HATCH_TILE_M, - ); - tris.push( - previous.x, - previous.y, - previous.top, - current.x, - current.y, - current.bottom, - previous.x, - previous.y, - previous.bottom, - ); - uvs.push( - uPrev, - previous.top / HATCH_TILE_M, - uCurrent, - current.bottom / HATCH_TILE_M, - uPrev, - previous.bottom / HATCH_TILE_M, - ); - stats.triangles += 2; - stats.maxDropM = Math.max(stats.maxDropM, dropA, dropB); - } - } else if (previous || current) { - stats.skippedNoGround += 1; - } - if (previous && current) { - walked += Math.hypot(current.x - previous.x, current.y - previous.y); - } - previous = current; - } - stats.detail.push({ - side: ribbon.side, - rows, - cols, - from: ribbon.chainages[0], - to: ribbon.chainages[rows - 1], - col0, - colLast, - gateDrop, - reachesGround, - triangles: tris.length / 9, - }); - if (tris.length) { - walls.push({ - side: ribbon.side, - positions: new Float64Array(tris), - patchSkirt: true, - uvs: new Float32Array(uvs), - }); - stats.ribbons += 1; - } - } - return { walls, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts deleted file mode 100644 index c3571196a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Station.ts +++ /dev/null @@ -1,607 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Station.ts - * 코리도 측점 분류 · 행 지반 트림 유틸 (Build에서 분리 — 700줄 제한). - * - * classifyStation: 측점 하나의 design_line을 종류별(차도/노견/측구/절·성토 비탈) - * 조각으로 자르고 리샘플한다. 계산식 재구현 금지 — 백엔드 design_line을 offset - * 경계로 분류만 한다. - * trimSlopeToGround: 비탈을 행 지반선과 직접 교차시켜 종결 — 절성토 완전 분리 - * (2026-08-23 사용자 지시)의 핵심. - * ========================================================================== */ - -import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; - -export type CorridorKind = "carriageway" | "shoulder" | "ditch" | "cut" | "fill"; -export type CorridorSide = "left" | "right" | "center"; - -/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. - * 절·성토는 구조물 구간에서 [노견 → 연장 → 벽 상단 → 벽 전면 → 성토부선 → 다단 - * 벽들 → 지반]까지 한 줄로 담아야 해 넉넉히 잡는다(2026-08-23 사용자 — 기슭막이 - * 하단 성토사면·추가 기슭막이 반영). */ -export const PIECE_COLS: Record = { - carriageway: 3, - // 노견 3열 = [차도끝, 원 노견끝, 바깥끝]. 2열이면 배수관 측점의 노폭 연장을 담을 때 - // 원 노견 끝의 꺾임이 사라져 노견 물매(3%)가 연장분까지 퍼진다(2026-08-24 사용자). - shoulder: 3, - ditch: 5, - cut: 20, - // 성토 조각은 다단 기슭막이의 계단(거의 수직인 앞면)을 담아야 한다. 20열이면 호길이 - // 균등 재추출이 수직면을 뭉갠다(2026-08-24). 열 수는 모든 측점이 같아야 리본이 - // 꼬이지 않으므로, 꼭짓점 보존 대신 **열을 늘려** 해상도로 해결한다. - fill: 48, -}; - -export interface XY { - x: number; - y: number; -} - -export interface OffsetPoint { - offset_m: number; - elevation_m: number; -} - -/** 측점 하나의 분류·리샘플 결과 — (kind,side)별 colCount 고정 폴리라인. */ -export 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, 지반z] (시·종점에서만 쓴다). */ - capSection: Array<[number, number, number]>; - /** - * 측구 조각을 **도로 끝(road edge) 기준 상대 좌표**로도 들고 있는다. - * - * 도로부(차도·노견·측구)는 한 몸이다 — 그런데 측구는 있는 측점과 없는 측점이 - * 갈려서, 절대 좌표로 따로 보간하면 도로 끝은 다음 측점을 향해 내려가는데 측구만 - * 제자리에 남아 최대 0.9m까지 벌어졌다(2026-08-23 실측). 상대 좌표로 들고 있다가 - * 프레임의 도로 끝에 얹으면 무슨 일이 있어도 노견에 붙는다. - */ - ditchRelative: Map; - /** - * 절·성토 비탈을 **비탈 시작점(도로부 바깥 끝) 기준 상대 좌표**로도 들고 있는다. - * - * 측구와 같은 규칙(도로부 한 몸 제어) — 행마다 도로부 끝 앵커에 얹으면 경사 - * 형상(1:1.2 등)이 그대로 유지된 채 위치만 따라온다(2026-08-23 완전 분리). - */ - slopeRelative: Map; - /** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */ - ground: ((offset: number) => number) | null; - /** - * 배수관 세트 기슭막이의 종방향 점유 구간(2026-08-23 사용자 — 성토부 형태 변경). - * 이 구간 안 행은 그 측의 비탈을 이 측점의 트림 사면(노견 연장 + 벽 상단 종결) - * 그대로 쓰고 **지반 트림을 하지 않는다** — 벽에서 끝난 사면을 지반까지 도로 - * 늘리면 안 된다. - */ - wallSpans?: Partial>; - /** - * 노견 확장 구간(2026-08-25 사용자 ①) — 구조물 실루엣의 수평 확장점까지 넓힌 - * 노견을 구조물 구간 내내 유지하기 위한 제어점 범위. 비탈 교체(wallSpans)와 - * 달리 **노견 조각에만** 걸린다. - */ - shoulderSpans?: Partial>; -} - -/** - * 측점 분류 선택(115-4). - * - * `silhouette` — 구조물 실루엣으로 **비탈 조각을 강제로 갈아 끼우던** 옛 방식 A - * (08-25 `6e87dd6cd` 「실루엣 갈아 끼우기」). 기본 끔(2026-08-25 사용자 지시: 기존 성토면을 - * 강제 변형시키던 로직 비활성화) — 끄면 비탈은 측점 설계선 그대로 지반까지 가고 구조물과 - * 겹치는 자리는 탑뷰 투영으로 도려낸다. 켜면 그날 그대로 **배수관 세트만** 갈아 끼우고 - * (BOX · 세월교는 원본 비탈) 노견 확장 상시 적용(08-25 ①, 그날 뒤)은 하지 않는다. - */ -export interface ClassifyOptions { - silhouette?: boolean; -} - -export 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개 점으로 리샘플 — 프레임 간 열 대응용. */ -export 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)로 본다. */ -export 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; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지. -} - -/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */ -function relativeToRoadEdge( - pieces: Map, - roadEdge: { left: OffsetPoint; right: OffsetPoint }, -): Map { - const relative = new Map(); - (["left", "right"] as const).forEach((side) => { - const key = pieceKey("ditch", side); - const points = pieces.get(key); - if (!points) return; - const edge = roadEdge[side]; - relative.set( - key, - points.map((point) => ({ - offset_m: point.offset_m - edge.offset_m, - elevation_m: point.elevation_m - edge.elevation_m, - })), - ); - }); - return relative; -} - -/** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */ -export function classifyStation( - section: CrossSection, - /** `.map(classifyStation)` 이 넘기는 인덱스(number)는 끔으로 읽는다. */ - options?: ClassifyOptions | number, -): StationPieces | null { - const design = section.design; - const line = design?.design_line; - if (!design || !line || line.length < 2) return null; - const roadL = design.road_edges.left.offset_m; - const roadR = design.road_edges.right.offset_m; - // 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 - // 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 - // 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다. - const ford = section.ford_pavement; - const carved = - ford && ford.depth_m - ? line.map((point) => - point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9 - ? { - ...point, - elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m), - } - : point, - ) - : line; - const sorted = [...carved].sort((a, b) => a.offset_m - b.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; - } - // 측구 바깥(노견 반대측) 상단은 **노견과 같은 Z**로 맞춘다 — 설계선 표본이 - // 모서리를 비껴가거나 L형처럼 바닥에서 끝나면 그 자리가 노견보다 낮게(또는 - // 지반을 따라) 그려진다(2026-08-23 사용자 지시). - const ditchKey = pieceKey("ditch", ditchSide); - const ditchPiece = pieces.get(ditchKey); - if (ditchPiece && ditchPiece.length >= 2) { - const edgeZ = design.road_edges[ditchSide].elevation_m; - const outer = ditchSide === "left" ? ditchPiece[ditchPiece.length - 1] : ditchPiece[0]; - outer.elevation_m = edgeZ; - } - } - // 비탈 끝 = 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); - - // ── 배수관 세트 측점(2026-08-23 사용자) — 구조물이 선 측의 단면을 B06 횡단 - // 그대로 다시 그린다. 그 측 비탈 한 줄에 담는 것: - // 노견 → (성토로 늘어난 노폭 = 수평 연장) → 구조물 상단 → 구조물 전면 → - // 벽 하단 성토부선 → 추가(다단) 기슭막이들 → 지반(또는 절토선 끝). - // 노폭 연장분은 비탈이 아니라 **노견 리본**을 넓히고, 집수정이 원지반을 - // 파고들면 절토선 끝까지 이어 그 만큼 원지반을 걷어낸다. - let wallSpans: StationPieces["wallSpans"]; - - /** - * 구조물 실루엣 적용 — 그 측 비탈 조각을 통째로 갈아 끼우고 종방향 점유 구간을 - * 등록한다. 배수관 세트·BOX암거·세월교가 공유한다(2026-08-25 사용자: 성토면 반영). - * - * `keepOrder`면 수집 순서(도로 → 바깥)를 지키고 내림차순일 때만 뒤집는다 — 거의 - * 수직인 벽 앞면·다단 계단은 편거리 정렬이 순서를 보장하지 못한다. 뒤쪽 코드 - * (`slopeRelative`)가 도로측 끝점을 **배열 위치**로 집으므로 항상 오름차순이어야 한다. - */ - const applySilhouette = ( - side: "left" | "right", - rawPoints: OffsetPoint[], - span: { beforeM: number; afterM: number }, - wanted: CorridorKind, - keepOrder: boolean, - ): void => { - if (rawPoints.length < 2) return; - const outward = side === "left" ? 1 : -1; - const edge = { - offset_m: side === "left" ? roadL : roadR, - elevation_m: design.road_edges[side].elevation_m, - }; - let silhouette = rawPoints; - // 노폭 연장(성토로 늘어난 노견)은 비탈이 아니라 노견 조각을 넓힌다. - if (!(ditchWidth > 0 && ditchSide === side)) { - let widening: OffsetPoint | null = null; - for (const point of silhouette) { - const outer = (point.offset_m - edge.offset_m) * outward; - if (outer > 1e-6 && Math.abs(point.elevation_m - edge.elevation_m) < 1e-6) { - if (!widening || (point.offset_m - widening.offset_m) * outward > 0) widening = point; - } - } - if (widening) { - const shoulder = pieces.get(pieceKey("shoulder", side)); - if (shoulder && shoulder.length >= 2) { - // 원 노견 끝을 꼭짓점으로 남긴다 — 바깥 정점만 밀면 [차도끝→연장끝] 직선 - // 하나가 되어 노견 물매가 연장분 전체로 퍼진다(2026-08-24 사용자 지적). - const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1]; - const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m }; - const outer = { offset_m: widening.offset_m, elevation_m: widening.elevation_m }; - pieces.set( - pieceKey("shoulder", side), - side === "left" ? [inner, knee, outer] : [outer, knee, inner], - ); - } - const cutAt = widening; - silhouette = silhouette.filter( - (point) => (point.offset_m - cutAt.offset_m) * outward > -1e-9, - ); - // 연장분을 노견이 가져갔으면 비탈은 **연장 끝에서** 시작해야 한다. 남는 점이 - // 없다고 그냥 빠지면 원 노견 끝에서 시작하는 옛 비탈이 살아남아 노견 밑을 - // 파고든다(2026-08-24 실측: 성토비탈이 연장부와 0.9m 겹침). - if (silhouette.length < 2) silhouette = [widening, widening]; - } - } - if (silhouette.length < 2) return; - if (!keepOrder) { - silhouette = [...silhouette].sort((a, b) => a.offset_m - b.offset_m); - } else if (silhouette[0].offset_m > silhouette[silhouette.length - 1].offset_m) { - silhouette = [...silhouette].reverse(); - } - // 종류가 바뀌면 옛 조각을 지운다 — 두 종류가 겹쳐 그려지면 안 된다. - (["cut", "fill"] as const).forEach((other) => { - if (other !== wanted) pieces.delete(pieceKey(other, side)); - }); - pieces.set(pieceKey(wanted, side), resample(silhouette, PIECE_COLS[wanted])); - wallSpans = wallSpans ?? {}; - wallSpans[side] = span; - }; - - // ── 구조물 측점(배수관 세트·BOX암거·세월교) — 그 측 비탈을 B06 횡단 실루엣으로 - // 갈아 끼우고 종방향 점유 구간을 등록한다. 기본은 꺼져 있다(options.silhouette · - // 옛 방식 A 단추만 켬) — 성토면은 그대로 두고 투영커브로 도려낸다. - - const silhouettes = structureSilhouettes(section); - const legacy = typeof options === "object" && options.silhouette === true; - const drainageOnly = section.box || section.ford ? [] : silhouettes; - for (const silhouette of legacy ? drainageOnly : []) { - const side = silhouette.side; - const kind: CorridorKind = - silhouette.kind === "auto" ? (side === "left" ? leftRole : rightRole) : silhouette.kind; - applySilhouette(side, silhouette.points, silhouette.span, kind, silhouette.keepOrder); - } - - // ── 노견 확장(2026-08-25 사용자 ① — 상시 적용): 구조물 실루엣의 수평 확장점까지 - // 노견 조각을 넓힌다. 원 노견 끝을 꼭짓점으로 남긴다(무릎 — 물매 유지). 넓힌 - // 노견은 shoulderSpans 제어점으로 구조물 구간 내내 유지된다. - let shoulderSpans: StationPieces["shoulderSpans"]; - for (const silhouette of legacy ? [] : silhouettes) { - const widen = silhouette.widenTo; - const side = silhouette.side; - if (!widen) continue; - if (ditchWidth > 0 && ditchSide === side) continue; // 측구 측은 종전대로. - const shoulder = pieces.get(pieceKey("shoulder", side)); - if (!shoulder || shoulder.length < 2) continue; - const edge = { - offset_m: side === "left" ? roadL : roadR, - elevation_m: design.road_edges[side].elevation_m, - }; - const inner = side === "left" ? shoulder[0] : shoulder[shoulder.length - 1]; - const knee = { offset_m: edge.offset_m, elevation_m: edge.elevation_m }; - const outer = { offset_m: widen.offset_m, elevation_m: widen.elevation_m }; - pieces.set( - pieceKey("shoulder", side), - side === "left" ? [inner, knee, outer] : [outer, knee, inner], - ); - shoulderSpans = shoulderSpans ?? {}; - shoulderSpans[side] = silhouette.span; - } - // 비탈 상대 좌표 — 안쪽(도로측) 끝점을 원점으로 둔다(행 앵커에 얹는 용도). - const slopeRelative = new Map(); - (["left", "right"] as const).forEach((side) => { - (["cut", "fill"] as const).forEach((kind) => { - const key = pieceKey(kind, side); - const points = pieces.get(key); - if (!points || points.length < 2) return; - const inner = side === "right" ? points[points.length - 1] : points[0]; - slopeRelative.set( - key, - points.map((point) => ({ - offset_m: point.offset_m - inner.offset_m, - elevation_m: point.elevation_m - inner.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 }, - }, - slopeRelative, - ground, - wallSpans, - shoulderSpans, - ditchRelative: relativeToRoadEdge(pieces, { - left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m }, - right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m }, - }), - // 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다. - capSection: slicePolyline(sorted, endR, endL).map( - (point) => - [ - point.offset_m, - point.elevation_m, - ground ? ground(point.offset_m) : point.elevation_m, - ] as [number, number, number], - ), - }; -} - -/** - * 단면(도로측 → 바깥)에서 **경계 편거리 안쪽을 잘라낸다**(2026-08-25 구조물 절단). - * 경계 바깥 조각만 남기고, 경계 지점을 보간해 첫 점으로 세운다 — 이 점이 행마다 - * 이어져 절단선(메시 모서리)이 된다. 전부 안쪽이면 null(조각 드롭). - */ -export function cutInnerTo( - inner2outer: OffsetPoint[], - boundaryOffset: number, - side: "left" | "right", -): OffsetPoint[] | null { - const sign = side === "left" ? 1 : -1; - const beyond = (point: OffsetPoint): number => (point.offset_m - boundaryOffset) * sign; - const kept: OffsetPoint[] = []; - for (let i = 0; i < inner2outer.length; i += 1) { - const point = inner2outer[i]; - if (beyond(point) >= -1e-9) { - if (!kept.length && i > 0) { - // 경계 통과 지점을 보간해 첫 점으로. - const prev = inner2outer[i - 1]; - const span = point.offset_m - prev.offset_m; - const t = Math.abs(span) < 1e-12 ? 0 : (boundaryOffset - prev.offset_m) / span; - kept.push({ - offset_m: boundaryOffset, - elevation_m: prev.elevation_m + (point.elevation_m - prev.elevation_m) * t, - }); - } - kept.push({ ...point }); - } - } - if (kept.length < 2) return null; - const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m); - return width < SLOPE_MIN_WIDTH_M ? null : kept; -} - -/** 비탈 폭 하한(m) — 트림 후 이보다 좁으면 그 행에서 비탈이 없는 것으로 본다. */ -export const SLOPE_MIN_WIDTH_M = 0.02; -/** 폴리라인 안에서 지반을 못 만날 때 마지막 구배로 연장하는 한계(m)·걸음(m). */ -const EXTEND_MAX_M = 30; -const EXTEND_STEP_M = 0.25; - -/** 두 측점 지반선을 t로 섞은 행 지반선 — 한쪽뿐이면 그쪽을 그대로 쓴다. */ -export function mixedGround( - a: ((offset: number) => number) | null, - b: ((offset: number) => number) | null, - t: number, -): ((offset: number) => number) | null { - if (!a || !b) return a ?? b; - return (offset: number): number => a(offset) * (1 - t) + b(offset) * t; -} - -/** - * 비탈 폴리라인(안→밖)을 행 지반선과 직접 교차시켜 종결한다(2026-08-23 사용자: - * 절성토 완전 분리 + 주변 데이터 반영). - * - 절토는 설계선이 지반 **아래**(파는 쪽)에서 시작해 위로 올라가 만나야 하고, - * 성토는 지반 **위**에서 시작해 내려가 만나야 한다. 시작부터 반대편이면 이 행에 - * 그 종류는 없다 → null(호출부가 조각을 지워 리본이 끊긴다). - * - 폴리라인 안에서 지반을 통과하면 그 지점까지 자르고, 못 만나면 마지막 구배로 - * 한 걸음씩 연장해 찾는다(EXTEND_MAX_M 한도 — 그래도 없으면 원래 끝 유지). - */ -export function trimSlopeToGround( - inner2outer: OffsetPoint[], - kind: "cut" | "fill", - groundAt: (offset: number) => number, -): OffsetPoint[] | null { - if (inner2outer.length < 2) return null; - // e = (설계 - 지반) × 부호: 유효 구간에서 양수, 지반 접점에서 0, 반대편이면 음수. - const sign = kind === "cut" ? -1 : 1; - const eOf = (point: OffsetPoint): number => (point.elevation_m - groundAt(point.offset_m)) * sign; - const first = inner2outer[0]; - if (eOf(first) < -CATCH_EPS_M) return null; - const kept: OffsetPoint[] = [{ ...first }]; - let prev = first; - let ePrev = eOf(first); - let closed = false; - for (let i = 1; i < inner2outer.length && !closed; i += 1) { - const point = inner2outer[i]; - const eNow = eOf(point); - if (eNow > CATCH_EPS_M) { - kept.push({ ...point }); - prev = point; - ePrev = eNow; - continue; - } - // 이 구간에서 지반 통과 — 교차 offset을 선형보간하고 z는 지반에 얹는다. - const ratio = ePrev - eNow <= 1e-12 ? 0 : ePrev / (ePrev - eNow); - const offset = prev.offset_m + (point.offset_m - prev.offset_m) * ratio; - kept.push({ offset_m: offset, elevation_m: groundAt(offset) }); - closed = true; - } - if (!closed) { - // 깊은 절토·높은 성토가 단면 밖으로 나간 경우 — 마지막 구배로 연장해 지반을 찾는다. - const tail = inner2outer[inner2outer.length - 1]; - const before = inner2outer[inner2outer.length - 2]; - const runOffset = tail.offset_m - before.offset_m; - const runLength = Math.hypot(runOffset, tail.elevation_m - before.elevation_m); - if (Math.abs(runOffset) > 1e-9 && runLength > 1e-9) { - const stepOffset = (runOffset / runLength) * EXTEND_STEP_M; - const stepZ = ((tail.elevation_m - before.elevation_m) / runLength) * EXTEND_STEP_M; - let cursor = { ...tail }; - let eCursor = eOf(cursor); - for (let step = 0; step * EXTEND_STEP_M < EXTEND_MAX_M; step += 1) { - const next = { - offset_m: cursor.offset_m + stepOffset, - elevation_m: cursor.elevation_m + stepZ, - }; - const eNext = eOf(next); - if (eNext <= CATCH_EPS_M) { - const ratio = eCursor - eNext <= 1e-12 ? 0 : eCursor / (eCursor - eNext); - const offset = cursor.offset_m + stepOffset * ratio; - kept.push({ offset_m: offset, elevation_m: groundAt(offset) }); - closed = true; - break; - } - cursor = next; - eCursor = eNext; - kept.push({ ...cursor }); - } - } - } - const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m); - if (kept.length < 2 || width < SLOPE_MIN_WIDTH_M) return null; - return kept; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts deleted file mode 100644 index 6a1c3afd3..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin.ts +++ /dev/null @@ -1,375 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Tin.ts - * 3D 제안 ② **단일 삼각망(TIN) 완성 지반**(계획서 115-6 · Civil 3D · OpenRoads 와 같은 원리). - * - * ① 설계 점 · 브레이크라인(`_Tin_Design.ts`) — 측점 단면 · 1 m 보간 단면 · 비탈 끝 폐곡선. - * ② 지형 이음 — 설계 구역(+ 구조물 구멍)에 닿는 지형 삼각형만 버리고, 그 삼각형의 바깥 - * 정점 · 변(원래 그대로)을 점 · 제약으로 넣어 제약 들로네 한 번 → 닿지 않은 지형은 원본, - * 버린 자리 안 바깥 지형도 원래 변이 제약이라 원래 삼각형 그대로 · 틈 0. - * ③ 구조물 솔리드는 그대로 — 윗면 외곽 안 삼각형만 지운다. - * ④ 색 = 삼각형마다 구역(성토 · 절토 = 면 색 · 원지반 = 원래 정점 색) · 법선 = 원본과 같게. - * - * 원본 · 분할본 geometry 는 건드리지 않는다(사본만). 변형 장 · 구조물 모듈은 읽기만 한다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; -import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { CellRegion } from "./B05_Profile_UI_Corridor_Region"; -import { buildDeformField, stationProfileSource } from "./B05_Profile_UI_Corridor_Deform"; -import { noFillLimits, structureSlopes } from "./B05_Profile_UI_Corridor_Deform_Structure"; -import { buildTinDesign, type TinDesign } from "./B05_Profile_UI_Corridor_Tin_Design"; -import { - EDGE_TERRAIN, - LoopIndex, - segmentTouchesTriangle, - SegGrid, - TAG_CUT, - TAG_FILL, - TAG_TERRAIN, - TriGrid, -} from "./B05_Profile_UI_Corridor_Tin_Pslg"; - -/** 절토 · 성토 색 — 새 방식(`_Deform_Terrain.ts`) · 리본 색과 같은 값. */ -const CUT_COLOR = new THREE.Color(0xb5179e); -const FILL_COLOR = new THREE.Color(0x06b6d4); -/** 원지반 색을 못 찾은 점(지형 밖) — 회색. */ -const FALLBACK_RGB = [0.6, 0.6, 0.6]; -/** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */ -const TINT_MIN_M = 0.01; -const GRID_M = 2; -/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */ -const ON_BOUNDARY_M = 1e-3; - -/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */ -class RoadStrip extends CellRegion { - constructor(road: TinDesign["road"]) { - super(); - const p = ([x, z]: [number, number]) => ({ x, z }); - for (let i = 0; i + 1 < road.left.length; i += 1) { - this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1])); - } - this.buildIndex(); - } -} - -/** 화면 밖 수치 확인용(`__corridorTin`). */ -export interface TinStats { - rows: number; - sections: number; - bowtie: number; - bendLines: number; - holes: number; - splits: number; - removed: number; - points: number; - constraints: number; - failed: number; - triangles: number; - ms: number; -} - -/** 지형 조각 하나에서 설계 구역에 닿는 삼각형을 골라 낸다 — 바깥 정점 · 변은 PSLG 로. */ -function harvestPart( - geometry: THREE.BufferGeometry, - design: TinDesign, - inRegion: (x: number, z: number) => boolean, - boundary: SegGrid, - box: { minX: number; maxX: number; minZ: number; maxZ: number }, - removedGrid: TriGrid, - removedRgb: number[], -): { kept: number[]; removed: number } { - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const index = geometry.getIndex(); - const triCount = index ? index.count / 3 : position.count / 3; - const state = new Int8Array(position.count); // 0 모름 · 1 안 · 2 밖 - const inside = (v: number): boolean => { - if (!state[v]) { - const x = position.getX(v); - const z = position.getZ(v); - // 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게). - state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2; - } - return state[v] === 1; - }; - const ids = new Int32Array(position.count).fill(-1); - const pslg = design.pslg; - const idOf = (v: number): number => { - if (ids[v] < 0) { - ids[v] = pslg.addPoint(position.getX(v), position.getZ(v), position.getY(v), TAG_TERRAIN); - if (color && pslg.tag[ids[v]] === TAG_TERRAIN) { - pslg.rgb[ids[v] * 3] = color.getX(v); - pslg.rgb[ids[v] * 3 + 1] = color.getY(v); - pslg.rgb[ids[v] * 3 + 2] = color.getZ(v); - } - } - return ids[v]; - }; - const kept: number[] = []; - const plan = new Float64Array(6); - let removed = 0; - for (let t = 0; t < triCount; t += 1) { - const v = [0, 1, 2].map((k) => (index ? index.getX(t * 3 + k) : t * 3 + k)); - for (let k = 0; k < 3; k += 1) { - plan[k * 2] = position.getX(v[k]); - plan[k * 2 + 1] = position.getZ(v[k]); - } - const minX = Math.min(plan[0], plan[2], plan[4]); - const maxX = Math.max(plan[0], plan[2], plan[4]); - const minZ = Math.min(plan[1], plan[3], plan[5]); - const maxZ = Math.max(plan[1], plan[3], plan[5]); - let hit = false; - if (!(maxX < box.minX || minX > box.maxX || maxZ < box.minZ || minZ > box.maxZ)) { - hit = v.some(inside); - if (!hit) { - const s = boundary.segs; - hit = boundary - .near(minX, minZ, maxX, maxZ) - .some((id) => - segmentTouchesTriangle(s[id * 4], s[id * 4 + 1], s[id * 4 + 2], s[id * 4 + 3], plan), - ); - } - } - if (!hit) { - kept.push(v[0], v[1], v[2]); - continue; - } - removed += 1; - removedGrid.add(plan); - v.forEach((vertex) => { - if (color) removedRgb.push(color.getX(vertex), color.getY(vertex), color.getZ(vertex)); - else removedRgb.push(...FALLBACK_RGB); - }); - // 바깥 정점 · 바깥 변 — 원래 모양 그대로 다시 세우는 제약. - for (let k = 0; k < 3; k += 1) { - const a = v[k]; - const b = v[(k + 1) % 3]; - if (inside(a) || inside(b)) continue; - if ( - boundary.touches( - plan[k * 2], - plan[k * 2 + 1], - plan[((k + 1) % 3) * 2], - plan[((k + 1) % 3) * 2 + 1], - ) - ) - continue; - pslg.addEdge(idOf(a), idOf(b), EDGE_TERRAIN); - } - v.forEach((vertex) => { - if (!inside(vertex)) idOf(vertex); - }); - } - return { kept, removed }; -} - -/** 설계 점 · 브레이크라인 · 구멍을 내는 자리 — `groundAt` = 원지반 높이(모델 좌표). 못 세우면 null. */ -export type TinDesigner = ( - build: CorridorBuildResult, - groundAt: (x: number, y: number) => number | null, - bounds: ModelBounds, -) => TinDesign | null; - -/** 제안 ② 설계 — 새 방식 변형 장(행마다 비탈 끝)에서. */ -const deformDesign: TinDesigner = (build, groundAt, bounds) => { - const sections = build.sections ?? []; - const structures = build.structures ?? []; - const source = stationProfileSource(sections, structureSlopes(sections)); - const limits = noFillLimits(sections, structures); - const field = buildDeformField(build, source, groundAt, limits); - if (!field) return null; - return buildTinDesign( - field, - source, - groundAt, - limits, - structures, - sections.map((section) => section.chainage_m), - bounds, - ); -}; - -/** - * 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못 - * 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다. - * `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③). - * `finish` = 다 만든 TIN 망 손질(제안 ③ 사면 부드러운 법선). - */ -export function tinTerrain( - split: TerrainBandSplit, - build: CorridorBuildResult, - bounds: ModelBounds, - heightIndex: TerrainHeightIndex, - designer: TinDesigner = deformDesign, - /** 망을 다 만든 뒤 손질(제안 ③ 사면 법선) — 없으면 그대로. */ - finish?: (mesh: THREE.Mesh) => void, -): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null { - const started = performance.now(); - if (!build.sections?.length) return null; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const groundAt = (x: number, y: number): number | null => { - const height = heightIndex.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }; - const design = designer(build, groundAt, bounds); - if (!design) return null; - - const outer = new LoopIndex([design.outer]); - const holes = new LoopIndex(design.holes); - const inRegion = (x: number, z: number): boolean => outer.contains(x, z) || holes.contains(x, z); - const boundary = new SegGrid(GRID_M); - let segment = 0; - [design.outer, ...design.holes].forEach((loop) => { - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - boundary.add(segment, loop[j][0], loop[j][1], loop[i][0], loop[i][1]); - segment += 1; - } - }); - const box = { - minX: Math.min(outer.minX, holes.minX), - maxX: Math.max(outer.maxX, holes.maxX), - minZ: Math.min(outer.minZ, holes.minZ), - maxZ: Math.max(outer.maxZ, holes.maxZ), - }; - - const root = new THREE.Group(); - root.name = "terrain-tin"; - const removedGrid = new TriGrid(GRID_M); - const removedRgb: number[] = []; - let removed = 0; - let material = null as THREE.Material | null; - let itemSize = 3; - let hasColor = false; - let hasNormal = false; - split.near.forEach((part) => { - if (part.points) { - // 포인트클라우드는 망 대상이 아니다 — 분할본 그대로 얹는다. - const points = new THREE.Points(part.geometry, part.material as THREE.Material); - points.userData.owned = false; - root.add(points); - return; - } - const harvest = harvestPart( - part.geometry, - design, - inRegion, - boundary, - box, - removedGrid, - removedRgb, - ); - removed += harvest.removed; - if (harvest.removed && !material) { - material = Array.isArray(part.material) ? part.material[0] : part.material; - const color = part.geometry.getAttribute("color"); - hasColor = Boolean(color); - itemSize = color?.itemSize ?? 3; - hasNormal = Boolean(part.geometry.getAttribute("normal")); - } - // 남은 원래 삼각형 — 사본(정점 버퍼 그대로 복제 · 인덱스만 추림). - const geometry = part.geometry.clone(); - geometry.setIndex(harvest.kept); - const mesh = new THREE.Mesh(geometry, part.material); - mesh.userData.owned = true; - root.add(mesh); - }); - - const pslg = design.pslg; - // 설계 · 구멍 점의 원지반 색 — 버린 지형 삼각형 안 무게중심 보간. - for (let i = 0; i < pslg.count; i += 1) { - if (!Number.isNaN(pslg.rgb[i * 3])) continue; - const t = removedGrid.find(pslg.x[i], pslg.z[i]); - const w = t >= 0 ? removedGrid.weights(t, pslg.x[i], pslg.z[i]) : null; - for (let c = 0; c < 3; c += 1) { - pslg.rgb[i * 3 + c] = w - ? w[0] * removedRgb[t * 9 + c] + - w[1] * removedRgb[t * 9 + 3 + c] + - w[2] * removedRgb[t * 9 + 6 + c] - : FALLBACK_RGB[c]; - } - } - - const { triangles, failed } = pslg.triangulate(); - const road = new RoadStrip(design.road); - const positions: number[] = []; - const colors: number[] = []; - const keep: number[] = []; - for (let t = 0; t + 2 < triangles.length; t += 3) { - let a = triangles[t]; - let b = triangles[t + 1]; - let c = triangles[t + 2]; - const cx = (pslg.x[a] + pslg.x[b] + pslg.x[c]) / 3; - const cz = (pslg.z[a] + pslg.z[b] + pslg.z[c]) / 3; - // 버린 지형 자리 밖(볼록 껍질 채움)은 뺀다 — 설계 구역 안은 지형 빈 곳(DTM 결측) 위라도 남긴다. - const inDesign = outer.contains(cx, cz); - if (!inDesign && removedGrid.find(cx, cz) < 0) continue; - if (road.contains({ x: cx, z: cz }) || holes.contains(cx, cz)) continue; - // 위를 보게 감기(씬 Y-up) — 평면 (x, z) 에서 시계 방향이면 뒤집는다. - const turn = - (pslg.x[b] - pslg.x[a]) * (pslg.z[c] - pslg.z[a]) - - (pslg.z[b] - pslg.z[a]) * (pslg.x[c] - pslg.x[a]); - if (turn > 0) [b, c] = [c, b]; - // 구역 — 설계 구역 안이면 꼬리표 다수결(없으면 높이 차 부호) · 밖이면 원지반. - let zone = 0; - if (inDesign) { - let fill = 0; - let cut = 0; - [a, b, c].forEach((id) => { - if (pslg.tag[id] === TAG_FILL) fill += 1; - if (pslg.tag[id] === TAG_CUT) cut += 1; - }); - if (fill || cut) zone = fill >= cut ? TAG_FILL : TAG_CUT; - else { - const ground = heightIndex.heightAt(cx, cz); - const delta = ground === null ? 0 : (pslg.y[a] + pslg.y[b] + pslg.y[c]) / 3 - ground; - if (Math.abs(delta) >= TINT_MIN_M) zone = delta > 0 ? TAG_FILL : TAG_CUT; - } - } - const tint = zone === TAG_FILL ? FILL_COLOR : zone === TAG_CUT ? CUT_COLOR : null; - [a, b, c].forEach((id) => { - positions.push(pslg.x[id], pslg.y[id], pslg.z[id]); - const rgb = tint - ? [tint.r, tint.g, tint.b] - : [pslg.rgb[id * 3], pslg.rgb[id * 3 + 1], pslg.rgb[id * 3 + 2]]; - colors.push(...rgb); - if (itemSize === 4) colors.push(1); - keep.push(tint ? 1 : 0); - }); - } - const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); - if (hasColor) geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, itemSize)); - // 원본에 법선이 있으면 면 법선(비색인 = 면마다 따로) · 없으면 원본처럼 flatShading 재질 몫. - if (hasNormal) geometry.computeVertexNormals(); - geometry.computeBoundingSphere(); - geometry.computeBoundingBox(); - const mesh = new THREE.Mesh( - geometry, - material ?? new THREE.MeshLambertMaterial({ vertexColors: true }), - ); - mesh.name = "terrain-tin-mesh"; - mesh.userData.owned = true; - mesh.userData.keepColor = Uint8Array.from(keep); - finish?.(mesh); - root.add(mesh); - split.far.forEach((part) => { - const far = new THREE.Mesh(part.geometry, part.material); - far.userData.owned = false; - root.add(far); - }); - const stats: TinStats = { - ...design.stats, - removed, - points: pslg.count, - constraints: pslg.kinds.length, - failed, - triangles: positions.length / 9, - ms: Math.round(performance.now() - started), - }; - return { root, stats, design }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts deleted file mode 100644 index 548b15e96..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Tin_Design.ts +++ /dev/null @@ -1,349 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Tin_Design.ts - * 3D 제안 ② 단일 삼각망(TIN)의 **설계 점 · 브레이크라인**(계획서 115-6) — 순수 기하. - * - * · 행 = 새 방식 변형 장(`_Deform.ts` buildDeformField)의 노면부 행(0.2 m · 노선 길이 기준). - * 비탈 끝 · 단면 꺾임은 그 장이 측점 단면(classifyStation)에서 메시 지반으로 찾은 값. - * · 노면 끝 선(행마다) · 비탈 끝 선(행마다) = 종방향 브레이크라인 · 둘이 설계 구역 둘레. - * · 단면 행(측점 + 1 m 걸음) = 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임을 잇는 종방향 선. - * · 곡선 안쪽 겹침(bow-tie) = 앞 행 단면과 교차하는 행은 그 측에서 뺀다(겹친 점 제거). - * · 구조물 = 솔리드 윗면 외곽 고리(구멍) · 둘레 높이 = min(설계면, 윗면). - * - * 변형 장 · 단면 출처는 읽기만 한다. - * ========================================================================== */ - -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; -import { - profileZ, - type DeformField, - type DeformLimit, - type ProfileSource, -} from "./B05_Profile_UI_Corridor_Deform"; -import { - EDGE_ANCHOR, - EDGE_DESIGN, - EDGE_HOLE, - Pslg, - SegGrid, - TAG_ANCHOR, - TAG_CUT, - TAG_FILL, - TAG_HOLE, -} from "./B05_Profile_UI_Corridor_Tin_Pslg"; - -/** 단면 행 걸음(m) — 측점 사이 보간 단면. */ -export const SECTION_STEP_M = 1; -/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */ -const MIN_CATCH_M = 0.02; -/** 비탈 끝에서 이 안쪽 꺾임만 점으로 둔다(m). */ -const BEND_GAP_M = 0.05; -/** 비탈 끝 높이와 지반 차가 이보다 작으면 끝 점만 지반으로(단면 안 보정 없음 · 측점 단면 그대로)(m). */ -const DRAPE_MIN_M = 0.1; -/** 윗면으로 보는 높이 여유(m) · 윗면 최소 폭(m). */ -const TOP_TOLERANCE_M = 0.05; -const TOP_MIN_WIDTH_M = 0.05; - -const SIDES = ["left", "right"] as const; - -export interface TinDesign { - pslg: Pslg; - /** 설계 구역 둘레(씬 평면) — 좌 비탈 끝 → 끝 마구리 → 우 비탈 끝 → 시작 마구리. */ - outer: Array<[number, number]>; - /** 구조물 윗면 구멍 고리(씬 평면). */ - holes: Array>; - /** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */ - road: { left: Array<[number, number]>; right: Array<[number, number]> }; - stats: { - rows: number; - sections: number; - bowtie: number; - bendLines: number; - holes: number; - splits: number; - }; -} - -/** 구조물 솔리드 윗면 외곽 고리(모델 좌표 · 점마다 윗면 높이) — 관은 뺀다(다른 부재가 감쌈). */ -export function structureTopLoops( - structures: ReadonlyArray, -): Array> { - const loops: Array> = []; - for (const structure of structures) { - const rings = structure.rings; - if (structure.kind === "pipe" || !rings || rings.length < 2) continue; - let run: Array<{ - x: number; - y: number; - ux: number; - uy: number; - lo: number; - hi: number; - z: number; - }> = []; - const flush = (): void => { - if (run.length >= 2) { - const loop: Array<[number, number, number]> = run.map((r) => [ - r.x + r.ux * r.lo, - r.y + r.uy * r.lo, - r.z, - ]); - for (let i = run.length - 1; i >= 0; i -= 1) { - const r = run[i]; - loop.push([r.x + r.ux * r.hi, r.y + r.uy * r.hi, r.z]); - } - loops.push(loop); - } - run = []; - }; - rings.forEach((frame, i) => { - const polygon = structure.polygons?.[i] ?? structure.polygon; - if (!polygon?.length) return flush(); - let top = -Infinity; - polygon.forEach(([, elevation]) => (top = Math.max(top, elevation))); - let lo = Infinity; - let hi = -Infinity; - polygon.forEach(([offset, elevation]) => { - if (elevation < top - TOP_TOLERANCE_M) return; - lo = Math.min(lo, offset); - hi = Math.max(hi, offset); - }); - if (hi - lo < TOP_MIN_WIDTH_M) return flush(); - run.push({ - x: frame.cx, - y: frame.cy, - ux: frame.leftX, - uy: frame.leftY, - lo, - hi, - z: top + frame.dz, - }); - }); - flush(); - } - return loops; -} - -/** 점이 고리 안인가(광선 투사 · 모델 좌표). */ -export function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { - let hit = false; - for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { - const [xi, yi] = loop[i]; - const [xj, yj] = loop[j]; - if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; - } - return hit; -} - -/** - * 설계 점 · 브레이크라인 · 구멍을 PSLG 로 모은다(교차 제약은 나눠 둠). 지형 점은 부르는 쪽이 더한다. - * `groundAt` = 원지반 높이(모델 좌표 · 메시). - */ -export function buildTinDesign( - field: DeformField, - source: ProfileSource, - groundAt: (x: number, y: number) => number | null, - limits: ReadonlyArray, - structures: ReadonlyArray, - stationChainages: ReadonlyArray, - bounds: ModelBounds, -): TinDesign { - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const sx = (mx: number): number => mx - ox; - const sz = (my: number): number => -(my - oy); - const pslg = new Pslg(); - const rows = field.rows; - const n = rows.length; - const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 }; - - // 노면 끝 선 — 리본 바깥 끝 그대로(높이 = 리본). - const anchors = SIDES.map((side) => - rows.map((row) => { - const [x, y, z] = side === "left" ? row.left : row.right; - return pslg.addPoint(sx(x), sz(y), z - oz, TAG_ANCHOR); - }), - ); - anchors.forEach((ids) => pslg.addPath(ids, EDGE_ANCHOR)); - pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR); - pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR); - - /** 그 행 · 측 단면의 설계 높이(모델) — 성토는 지반 위로 · 절토는 아래로만 · 성토 금지 고리. - * `lift` = 드레이프 보정(비탈 끝에서 지형에 맞춘 몫 × s / 비탈 끝). */ - const designZ = (r: number, si: number, ki: number, s: number, lift = 0): number | null => { - const row = rows[r]; - const anchor = si === 0 ? row.left : row.right; - const shapes = source(row.chainage_m, SIDES[si]); - const profile = ki === 0 ? shapes.fill : shapes.cut; - if (!profile) return null; - const rel = profileZ(profile, s, shapes.noExtend); - if (rel === null) return null; - const [x, y] = field.pointAt(r, si, s); - const ground = groundAt(x, y); - let z = anchor[2] + rel + lift; - if (ground !== null) { - z = ki === 0 ? Math.max(z, ground) : Math.min(z, ground); - for (const limit of limits) { - if (z > ground && insideLoop(limit.loop, x, y)) - z = Math.min(z, Math.max(ground, limit.ceiling ?? ground)); - } - } - return z; - }; - - /** 행 · 측의 비탈 — 종류(0 성토 · 1 절토)와 비탈 끝 거리. 없으면 null. */ - const slopeOf = (r: number, si: number): { ki: number; reach: number } | null => { - const fill = field.catchAt(r, si, 0); - const cut = field.catchAt(r, si, 1); - const ki = fill >= cut ? 0 : 1; - const reach = Math.max(fill, cut); - return reach >= MIN_CATCH_M ? { ki, reach } : null; - }; - - const isStation = (chainage: number): boolean => - stationChainages.some((c) => Math.abs(c - chainage) < 1e-3); - - // 비탈 끝 점 · 곡선 안쪽 겹침 청소 — 앞 행들 단면 선분에 닿으면 처음 닿는 자리에서 자른다(겹친 점 제거). - const kindOf = SIDES.map(() => new Int8Array(n).fill(-1)); - const reachOf = SIDES.map(() => new Float64Array(n)); - const liftOf = SIDES.map(() => new Float64Array(n)); - const daylight = SIDES.map(() => new Int32Array(n).fill(-1)); - SIDES.forEach((_, si) => { - const fan = new SegGrid(2); - for (let r = 0; r < n; r += 1) { - const slope = slopeOf(r, si); - const anchor = anchors[si][r]; - daylight[si][r] = anchor; - if (!slope) continue; - let reach = slope.reach; - const ax = pslg.x[anchor]; - const az = pslg.z[anchor]; - const [dx, dy] = field.pointAt(r, si, reach); - const hit = fan.firstHit(ax, az, sx(dx), sz(dy)); - if (hit < 1) { - reach *= hit; - stats.bowtie += 1; - if (reach < MIN_CATCH_M) continue; - } - const [px, py] = field.pointAt(r, si, reach); - const line = designZ(r, si, slope.ki, reach); - const ground = groundAt(px, py); - // 드레이프(115-8 · 제안 ③ 과 같은 규칙) — 비탈 끝 높이 = 그 자리 지형 · 보정량은 단면에 s 비율로. - // 비탈 끝이 짧아진 행(115-7 튐 자름 · 못 닫힘)에서 설계면 높이 끝이 바깥 지형과 턱 지던 자리. - // 측점 행은 B06 단면 그대로(끝 점만 0.1 m 안이면 지반). - const step = ground !== null && line !== null ? ground - line : 0; - const station = isStation(rows[r].chainage_m); - if (hit >= 1 && !station && Math.abs(step) >= DRAPE_MIN_M) liftOf[si][r] = step; - const z = - hit >= 1 && ground !== null && (!station || line === null || Math.abs(step) < DRAPE_MIN_M) - ? ground - : (line ?? ground ?? rows[r].left[2]); - daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, slope.ki === 0 ? TAG_FILL : TAG_CUT); - kindOf[si][r] = slope.ki; - reachOf[si][r] = reach; - fan.add(r, ax, az, sx(px), sz(py)); - } - }); - - // 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. - const left: number[] = []; - const right: number[] = []; - for (let r = 0; r < n; r += 1) { - left.push(daylight[0][r]); - right.push(daylight[1][r]); - } - pslg.addPath(left, EDGE_DESIGN); - pslg.addPath(right, EDGE_DESIGN); - pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN); - pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN); - pslg.addPath([anchors[0][n - 1], left[left.length - 1]], EDGE_DESIGN); - pslg.addPath([anchors[1][n - 1], right[right.length - 1]], EDGE_DESIGN); - const loopIds = [ - anchors[0][0], - ...left, - anchors[0][n - 1], - anchors[1][n - 1], - ...right.reverse(), - anchors[1][0], - ]; - const outer = loopIds - .filter((id, i) => i === 0 || id !== loopIds[i - 1]) - .map((id): [number, number] => [pslg.x[id], pslg.z[id]]); - - // 단면 행 — 측점 + 1 m 걸음. 횡방향 선(노면 끝 → 꺾임 → 비탈 끝) · 같은 열 꺾임 종방향 선. - const sectionRows: number[] = []; - for (let r = 0; r < n; r += 1) { - const chainage = rows[r].chainage_m; - const previous = sectionRows.length - ? rows[sectionRows[sectionRows.length - 1]].chainage_m - : -Infinity; - if ( - r === 0 || - r === n - 1 || - isStation(chainage) || - chainage - previous >= SECTION_STEP_M - 1e-6 - ) { - sectionRows.push(r); - } - } - stats.sections = sectionRows.length; - SIDES.forEach((_, si) => { - let previous: { r: number; ki: number; bends: Map } | null = null; - for (const r of sectionRows) { - const ki = kindOf[si][r]; - const reach = reachOf[si][r]; - if (ki < 0) { - previous = null; - continue; - } - const bends = new Map(); - const path = [anchors[si][r]]; - for (const [col, s] of field.bendsAt(r, si, ki)) { - if (s <= MIN_CATCH_M || s >= reach - BEND_GAP_M) continue; - const z = designZ(r, si, ki, s, (liftOf[si][r] * s) / reach); - if (z === null) continue; - const [x, y] = field.pointAt(r, si, s); - const id = pslg.addPoint(sx(x), sz(y), z - oz, ki === 0 ? TAG_FILL : TAG_CUT); - path.push(id); - bends.set(col, [id, s]); - } - path.push(daylight[si][r]); - pslg.addPath(path, EDGE_DESIGN); - if (previous && previous.ki === ki) { - for (const [col, [id, s]] of bends) { - const before = previous.bends.get(col); - if (!before) continue; - // 사이 행 비탈 끝이 그 꺾임보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감). - let inside = true; - for (let k = previous.r + 1; k < r && inside; k += 1) { - const t = (k - previous.r) / (r - previous.r); - if (reachOf[si][k] < before[1] + (s - before[1]) * t + BEND_GAP_M) inside = false; - } - if (!inside) continue; - pslg.addEdge(before[0], id, EDGE_DESIGN); - stats.bendLines += 1; - } - } - previous = { r, ki, bends }; - } - }); - - // 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(드레이프). - const holes: Array> = []; - for (const loop of structureTopLoops(structures)) { - const ids = loop.map(([x, y, top]) => { - const ground = groundAt(x, y); - const surface = field.deformZ(x, y, ground ?? top); - return pslg.addPoint(sx(x), sz(y), Math.min(surface, top) - oz, TAG_HOLE); - }); - pslg.addPath(ids, EDGE_HOLE, true); - holes.push(loop.map(([x, y]): [number, number] => [sx(x), sz(y)])); - } - stats.holes = holes.length; - stats.splits = pslg.splitCrossings(); - - const plan = (si: number): Array<[number, number]> => - anchors[si].map((id): [number, number] => [pslg.x[id], pslg.z[id]]); - return { pslg, outer, holes, road: { left: plan(0), right: plan(1) }, stats }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts b/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts deleted file mode 100644 index 527209369..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Corridor_Union.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Corridor_Union.ts - * 스윕 링 사이의 **볼록 사각형** 한 칸 — 풋프린트 교차 판정(`CarveFootprint.outerAt`)이 - * 쓰는 자료형이다. - * - * 옛 기하 합집합(`unionOutline`)은 2026-08-26 삭제했다 — 겹친 스트립에서 외곽선이 - * 무너졌다(같은 토막이 짝수 번 나오면 버리는 규칙이 외곽의 공유 변까지 지웠다). - * 구조물 탑뷰 외곽은 `_Corridor_Plan_Footprint.ts`가 링별 구간 병합으로 낸다. - * ========================================================================== */ - -/** 실좌표 XY 점. */ -export interface PXY { - x: number; - y: number; -} - -/** 볼록 사각형 — 꼭짓점 4개(한 바퀴 순서) + AABB. */ -export interface UnionQuad { - points: [PXY, PXY, PXY, PXY]; - minX: number; - maxX: number; - minY: number; - maxY: number; -} - -export function unionQuadOf(a: PXY, b: PXY, c: PXY, d: PXY): UnionQuad { - return { - points: [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), - minY: Math.min(a.y, b.y, c.y, d.y), - maxY: Math.max(a.y, b.y, c.y, d.y), - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Panel_View.ts b/B05_Profile/Old/B05_Profile_UI_Panel_View.ts deleted file mode 100644 index 9e1ceec70..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Panel_View.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Panel_View.ts - * 3D 화면 위에 얹는 **보기 조작 줄**(ISO·TOP·… · 표시 켬끔 · 뷰 초기화 · [3D 업데이트]). - * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). - * ========================================================================== */ - -import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; -import { - corridorModeId, - MODE_SLOTS, - modeOf, - onCorridorMode, - setCorridorMode, -} from "./B05_Profile_UI_Corridor_Modes"; - -/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ -export interface ViewControlCallbacks { - onSurfaceVisible: (visible: boolean) => void; - onCorridorVisible: (visible: boolean) => void; - onContoursVisible: (visible: boolean) => void; - onAxesVisible: (visible: boolean) => void; - onStationLinesVisible: (visible: boolean) => void; - onStationLabelsVisible: (visible: boolean) => void; - onSurfaceGrayscale: (grayscale: boolean) => void; - onView: (view: "iso" | "top" | "front" | "side") => void; - onProjection: (kind: "perspective" | "ortho") => void; - onResetView: () => void; - onCorridorRefresh: () => void | Promise; -} - -export function createViewControls(callbacks: ViewControlCallbacks): { - viewControls: HTMLElement; - /** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */ - setCorridorPending: (pending: boolean) => void; -} { - const viewControls = document.createElement("div"); - viewControls.className = "b05-route__view-controls"; - const viewButtons = document.createElement("div"); - viewButtons.className = "b05-route__view-group"; - (["iso", "top", "front", "side"] as const).forEach((preset) => - viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), - ); - // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). - const projectionButtons = ( - [ - ["perspective", "원근"], - ["ortho", "직교"], - ] as const - ).map(([kind, label]) => { - const element = button( - label, - () => { - projectionButtons.forEach(({ kind: other, element: el }) => { - el.classList.toggle("is-active", other === kind); - el.setAttribute("aria-pressed", String(other === kind)); - }); - callbacks.onProjection(kind); - }, - "glass", - ); - element.classList.toggle("is-active", kind === "ortho"); - element.setAttribute("aria-pressed", String(kind === "ortho")); - return { kind, element }; - }); - viewButtons.append(...projectionButtons.map(({ element }) => element)); - // 3D 방식 고르기 한 줄(115-5) — 원근 · 직교와 같은 꼴. 등록 안 된 자리(제안 ②)는 꺼짐. - const modeButtons = document.createElement("div"); - modeButtons.className = "b05-route__view-group"; - const modeLabel = document.createElement("span"); - modeLabel.className = "b05-route__view-label"; - modeLabel.textContent = "3D 방식"; - const modeChoices = MODE_SLOTS.map(([id, label]) => { - const element = button(label, () => setCorridorMode(id), "glass"); - element.disabled = !modeOf(id); - if (element.disabled) element.title = "아직 없는 방식입니다."; - return { id, element }; - }); - const showMode = (): void => { - const current = corridorModeId(); - modeChoices.forEach(({ id, element }) => { - element.classList.toggle("is-active", id === current); - element.setAttribute("aria-pressed", String(id === current)); - }); - }; - showMode(); - onCorridorMode(showMode); - modeButtons.append(modeLabel, ...modeChoices.map(({ element }) => element)); - const visibilityButtons = document.createElement("div"); - 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), - toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), - // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). - // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. - toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), - ); - // [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이 - // 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은 - // 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장). - const corridorRefresh = button( - "3D 업데이트", - () => { - corridorRefresh.disabled = true; - corridorRefresh.textContent = "갱신 중…"; - void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => { - corridorRefresh.disabled = false; - corridorRefresh.textContent = "3D 업데이트"; - corridorRefresh.classList.remove("is-pending"); - }); - }, - "glass", - ); - corridorRefresh.title = - "계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다."; - const separator1 = document.createElement("span"); - separator1.className = "b05-route__view-separator"; - const separator2 = separator1.cloneNode() as HTMLSpanElement; - const separator3 = separator1.cloneNode() as HTMLSpanElement; - const separator4 = separator1.cloneNode() as HTMLSpanElement; - viewControls.append( - viewButtons, - separator1, - modeButtons, - separator4, - visibilityButtons, - separator2, - button("뷰 초기화", callbacks.onResetView, "glass"), - separator3, - corridorRefresh, - ); - - return { - viewControls, - setCorridorPending(pending: boolean) { - corridorRefresh.classList.toggle("is-pending", pending); - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts b/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts deleted file mode 100644 index adffbd73a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_RouteCalc.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* Old — 54-24 · 나중에 삭제 */ -/* ============================================================================= - * B05_Profile_UI_RouteCalc.ts - * 좌측 패널 「경로 계산 설정」 칸 — 포인트 팔레트 · 알고리즘 · 조건 · [최적 경로 계산](숨김). - * `B05_Profile_UI_Panel_Route.ts` · `_Panel_Sections.ts` · `_Page_Actions.ts` · `_Page_Helpers.ts` - * 에서 떼어 옮겼다. 어디서도 import 하지 않는다(tsconfig 제외). - * ========================================================================== */ - -import { - createSelectField, - hideLoadingOverlay, - showLoadingOverlay, - showToast, -} from "@ui/ui_template_elements"; -import { - createSectionFrame, - part, - registerPanelSection, -} from "../../A00_Common/design/design_panel"; -import { solveRoute, type CirclePoint, type RoutePoint } from "../B05_Profile_Api_Fetch"; -import type { PlacedRoutePoint, RoutePointKind } from "../B05_Profile_UI_Markers"; -import { button, checkbox, numberField, parseOptional } from "../B05_Profile_UI_Panel_Fields"; -import type { PageActionContext } from "../B05_Profile_UI_Page_Actions"; - -export function createRouteCalcPart(callbacks: { onSolve: () => void }) { - const paletteGrid = document.createElement("div"); - paletteGrid.className = "b05-route__palette"; - const pointLabels: Record = { - bp: "BP 시작점", - ep: "EP 종료점", - cp: "CP 경유점", - ap: "AP 회피구역", - fp: "FP 금지구역", - }; - (Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => { - const chip = document.createElement("div"); - chip.className = `b05-route__chip is-${kind}`; - chip.draggable = true; - chip.textContent = pointLabels[kind]; - chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind)); - paletteGrid.append(chip); - }); - - // 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변. - const algorithmField = createSelectField({ - label: "알고리즘", - options: [ - { value: "dijkstra", text: "Dijkstra" }, - { value: "ridge_valley", text: "능선·계곡" }, - ], - }); - const algorithm = algorithmField.select; - const minCurveRadius = numberField("최소 곡선반경 (m)"); - const maxUphillGrade = numberField("오르막 경사 상한 (%)"); - const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); - const minUphillGrade = numberField("오르막 경사 하한 (%)"); - const minDownhillGrade = numberField("내리막 경사 하한 (%)"); - const paved = checkbox("포장 임도", false); - const avoidPass = checkbox("회피구역 통과 허용", false); - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "사용한 조건"; - details.append( - summary, - minCurveRadius.wrapper, - maxUphillGrade.wrapper, - maxDownhillGrade.wrapper, - minUphillGrade.wrapper, - minDownhillGrade.wrapper, - ); - // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 - // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. - const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); - solveButton.hidden = true; - - return { - /** 「경로 계산 설정」 본문 — 화면 순서대로. */ - calcNodes: [ - paletteGrid, - algorithmField.root, - paved.wrapper, - avoidPass.wrapper, - details, - solveButton, - ] as HTMLElement[], - fields: { - algorithm, - paved, - avoidPass, - minCurveRadius, - maxUphillGrade, - maxDownhillGrade, - minUphillGrade, - minDownhillGrade, - }, - }; -} - -export type RouteCalcPart = ReturnType; - -registerPanelSection({ - id: "route-calc", - title: "경로 계산 설정", - views: ["profile"], - order: 40, - build: (ctx, view) => { - const frame = createSectionFrame(view, "경로 계산 설정", true); - frame.body.append(...part(ctx, "routeCalc").calcNodes); - // 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자 - // 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다. - // 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다. - frame.root.classList.add("is-disabled"); - frame.body.inert = true; - return frame.root; - }, -}); - -export function routePoint(point: PlacedRoutePoint): RoutePoint { - // 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다. - return { x: point.x, y: point.y, z: point.z ?? undefined }; -} - -export function circlePoint(point: PlacedRoutePoint): CirclePoint { - return { ...routePoint(point), radius_m: point.radius_m ?? 25 }; -} - -/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */ -export async function solveRouteAction( - ctx: PageActionContext, - calc: RouteCalcPart["fields"], -): Promise { - const latest = ctx.latest(); - const confirmedSurface = ctx.confirmedSurface(); - if (!confirmedSurface || !latest) return; - const points = ctx.viewer().markers.getPoints(); - if (!points.bp || !points.ep) { - showToast("BP와 EP를 지형에 배치하세요.", "error"); - return; - } - const values = ctx.panel().values(); - showLoadingOverlay(); - try { - const solved = await solveRoute(ctx.projectId, { - filter_key: latest.surface_params.source_filter, - method: latest.surface_params.method, - smooth: latest.surface_params.smooth, - surface_model_id: confirmedSurface.model_id ?? undefined, - algorithm: calc.algorithm.value, - bp: routePoint(points.bp), - ep: routePoint(points.ep), - cp: points.cp.map(routePoint), - ap: points.ap.map(circlePoint), - fp: points.fp.map(circlePoint), - grade_class: values.gradeClass, - paved: calc.paved.checked, - min_curve_radius_m: parseOptional(calc.minCurveRadius), - max_uphill_grade: parseOptional(calc.maxUphillGrade), - max_downhill_grade: parseOptional(calc.maxDownhillGrade), - min_uphill_grade: parseOptional(calc.minUphillGrade), - min_downhill_grade: parseOptional(calc.minDownhillGrade), - allow_avoid_pass_through: calc.avoidPass.checked, - station_interval_m: values.stationInterval, - cross_half_width_m: null, - cross_sample_interval_m: values.crossSampleInterval, - long_sample_interval_m: values.longSampleInterval, - terrain_type: values.terrainType, - design_speed_kph: values.designSpeed, - max_grade_pct: values.maxGradePct, - min_vertical_radius_m: values.minVerticalRadius, - min_tangent_length_m: values.minTangentLength, - start_elevation_offset_m: values.startElevationOffset, - end_elevation_offset_m: values.endElevationOffset, - enforce_pipe_clearance: values.enforcePipeClearance, - }); - // 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용). - ctx.uphillOverrides.clear(); - ctx.persistUphillOverrides(); - ctx.renderLatest(await ctx.loadLatest(true)); - await ctx.restoreSections(solved.route_id); - if (solved.cross_section_count === null) { - showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error"); - } else if (solved.grade_summary === null) { - showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error"); - } else { - showToast("최적 경로 계산이 완료되었습니다.", "success"); - } - } catch (error) { - showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error"); - } finally { - hideLoadingOverlay(); - } -} diff --git a/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts deleted file mode 100644 index a4ae28898..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Viewer_Corridor.ts +++ /dev/null @@ -1,317 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Corridor.ts - * 3D 뷰어의 예상형상(코리도) 조각 — 원본/클리핑 지형 스왑 + 계획 서피스 조합. - * - * 뷰어 본체(`B05_Profile_UI_Viewer.ts`)가 700줄을 넘어 이 부분만 뗐다(계획서 24-3a). - * 지형 · 범위 · 높이 색인은 뷰어가 쥐고, 여기서는 받아 읽기만 한다. - * ========================================================================== */ - -import * as THREE from "three"; -import type { ModelBounds } from "./B05_Profile_UI_Markers"; -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; -import { - createCorridorGroup, - PLAN_CURVE_FLAG, - PLAN_CURVE_GROUP, -} from "./B05_Profile_UI_Corridor_Mesh"; -import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; -import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; -import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; -import { - activeCorridorMode, - DEFAULT_MODE, - MODE_SLOTS, - modeOf, - onCorridorMode, - type CorridorMode, -} from "./B05_Profile_UI_Corridor_Modes"; -import { showToast } from "@ui/ui_template_elements"; - -/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ -function countTriangles(root: THREE.Object3D | null): number { - let total = 0; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh)) return; - const index = child.geometry.getIndex(); - const position = child.geometry.getAttribute("position"); - total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3); - }); - return total; -} - -/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */ -function describeSurface(root: THREE.Object3D | null): unknown { - const out: unknown[] = []; - root?.traverse((child) => { - if (!(child instanceof THREE.Mesh) || out.length >= 2) return; - const geometry = child.geometry; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; - const material = ( - Array.isArray(child.material) ? child.material[0] : child.material - ) as THREE.MeshLambertMaterial; - out.push({ - type: material?.type, - vertexColors: material?.vertexColors, - matColor: material?.color?.getHexString(), - side: material?.side, - colorSize: color?.itemSize, - color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null, - normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null, - hasNormal: Boolean(normal), - }); - }); - return out; -} - -export function disposeObject(object: THREE.Object3D | null): void { - object?.traverse((child) => { - if ( - child instanceof THREE.Mesh || - child instanceof THREE.Points || - child instanceof THREE.Line - ) { - child.geometry.dispose(); - const materials = Array.isArray(child.material) ? child.material : [child.material]; - materials.forEach((material) => material.dispose()); - } - }); -} - -export interface ViewerCorridorDeps { - scene: THREE.Scene; - terrain: () => THREE.Object3D | null; - bounds: () => ModelBounds | null; - heightIndex: () => TerrainHeightIndex | null; - /** 코리도 그룹을 새로 만들었을 때 — 구조물 강조를 다시 입힌다. */ - onGroup: () => void; - /** 클리핑본에도 흑백 토글을 입힌다. */ - applyGrayscale: (target: THREE.Object3D | null) => void; -} - -export interface ViewerCorridor { - set: (build: CorridorBuildResult | null) => void; - setVisible: (visible: boolean) => void; - setSurfaceVisible: (visible: boolean) => void; - /** 지형이 바뀌기 전 — 밴드 분할본을 버린다. */ - resetTerrain: () => void; - /** 지형 · 범위가 준비된 뒤 — 미뤄 둔 코리도를 다시 조립한다. */ - rebuild: () => void; - group: () => THREE.Group | null; - clipped: () => THREE.Object3D | null; - dispose: () => void; -} - -export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { - const { scene } = deps; - let corridorBuild: CorridorBuildResult | null = null; - let corridorGroup: THREE.Group | null = null; - let clippedTerrain: THREE.Object3D | null = null; - /** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */ - let bandSplit: TerrainBandSplit | null = null; - let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. - let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). - /** 지금 그리는 방식(115-5 고르기 한 줄)과 그 방식이 낸 코리도 빌드. */ - let mode: CorridorMode = activeCorridorMode(); - let drawnBuild: CorridorBuildResult | null = null; - const offMode = onCorridorMode(() => { - if (corridorBuild) set(corridorBuild); - }); - - /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ - function applyCorridorVisibility(): void { - const terrain = deps.terrain(); - 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); - // 분할본이 들고 있는 geometry·material은 여기서 해제하지 않는다 — 다음 재트림에 - // 그대로 다시 쓴다. 이 그룹이 새로 만든 것(userData.owned)만 정리한다. - clippedTerrain.traverse((child) => { - if (!(child instanceof THREE.Mesh) && !(child instanceof THREE.Points)) return; - if (child.userData.owned) child.geometry.dispose(); - }); - clippedTerrain = null; - } - - /** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */ - function corridorBand(build: CorridorBuildResult): SceneBox | null { - const bounds = deps.bounds(); - if (!bounds) return null; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - let minX = Infinity; - let maxX = -Infinity; - let minZ = Infinity; - let maxZ = -Infinity; - [...build.outline.left, ...build.outline.right].forEach(([mx, my]) => { - const x = mx - ox; - const z = -(my - oy); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; - }); - if (!Number.isFinite(minX)) return null; - return { minX, maxX, minZ, maxZ }; - } - - /** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */ - function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null { - const terrain = deps.terrain(); - if (!terrain) return null; - const box = corridorBand(build); - if (!box) return null; - if (bandSplit?.covers(box)) return bandSplit; - bandSplit?.dispose(); - bandSplit = new TerrainBandSplit(terrain, { - minX: box.minX - BAND_MARGIN_M, - maxX: box.maxX + BAND_MARGIN_M, - minZ: box.minZ - BAND_MARGIN_M, - maxZ: box.maxZ + BAND_MARGIN_M, - }); - return bandSplit; - } - - function disposeCorridorGroup(): void { - if (!corridorGroup) return; - scene.remove(corridorGroup); - disposeObject(corridorGroup); - corridorGroup = null; - } - - /** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */ - function scheduleTerrainClip(): void { - disposeClippedTerrain(); - if (!corridorBuild || !deps.terrain() || !deps.bounds()) { - applyCorridorVisibility(); - return; - } - const buildAtSchedule = corridorBuild; - requestAnimationFrame(() => { - const terrain = deps.terrain(); - const bounds = deps.bounds(); - // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. - if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const drawn = drawnBuild ?? buildAtSchedule; - const split = ensureBandSplit(drawn); - if (!split) return; - // 방식이 지형을 안 내면 그 방식 빌드로 띠만 자른다(지금 · 옛 A). - const shaped = - mode.terrain?.({ - build: buildAtSchedule, - corridor: drawn, - split, - bounds, - heightIndex: deps.heightIndex(), - }) ?? null; - (window as unknown as { __corridorDeform?: unknown }).__corridorDeform = - shaped?.stats ?? null; - const clipped = shaped?.root ?? clipTerrain(split, drawn, bounds); - disposeClippedTerrain(); - clippedTerrain = clipped; - scene.add(clipped); - // 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다. - (window as unknown as { __corridorClip?: unknown }).__corridorClip = { - source: countTriangles(terrain), - clipped: countTriangles(clipped), - detail: describeSurface(clipped), - sourceDetail: describeSurface(terrain), - }; - deps.applyGrayscale(clipped); // 흑백 토글 상태 유지. - applyCorridorVisibility(); - }); - applyCorridorVisibility(); - } - - function set(build: CorridorBuildResult | null): void { - const picked = activeCorridorMode(); - // 처음 만들기가 무거운 방식(옛 A 즉석 빌드 · 몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다. - if (build && picked.slow?.(build)) { - corridorBuild = build; - const label = MODE_SLOTS.find(([id]) => id === picked.id)?.[1] ?? picked.id; - showToast(`3D 방식 「${label}」 형상을 만드는 중입니다…`, "info"); - window.setTimeout(() => { - if (corridorBuild !== build || activeCorridorMode() !== picked) return; - const bounds = deps.bounds(); - if (bounds) picked.corridor(build, { bounds, heightIndex: deps.heightIndex() }); - set(build); - }, 50); - return; - } - disposeCorridorGroup(); - corridorBuild = build; - mode = picked; - drawnBuild = null; - const bounds = deps.bounds(); - if (build && bounds) { - const ctx = { bounds, heightIndex: deps.heightIndex() }; - drawnBuild = mode.corridor(build, ctx); - // 그 방식이 못 그리면(옛 A 입력 없음) 기본 방식으로. - if (!drawnBuild) { - mode = modeOf(DEFAULT_MODE) as CorridorMode; - drawnBuild = mode.corridor(build, ctx); - } - } - setCorridorBuildSummary(drawnBuild ?? build); - if (drawnBuild && bounds) { - corridorGroup = createCorridorGroup(drawnBuild, bounds); - scene.add(corridorGroup); - deps.onGroup(); - } - // 평면 스케치 되켜기 — 최종 결과물에서는 숨기지만 절취·패치 기하를 다시 볼 때 쓴다 - // (2026-09-02 사용자: "나중에 디버깅을 위해 재사용 가능성 있음"). - // `__corridorPlanCurves(true)` 로 켜고 `(false)` 로 끈다. 선택은 이 브라우저에 남아 - // 다음에 열 때도 그대로다. 인자 없이 부르면 지금 상태를 돌려준다. - ( - window as unknown as { __corridorPlanCurves?: (on?: boolean) => boolean } - ).__corridorPlanCurves = (on?: boolean): boolean => { - const sketch = corridorGroup?.getObjectByName(PLAN_CURVE_GROUP); - if (on !== undefined) { - try { - window.localStorage.setItem(PLAN_CURVE_FLAG, on ? "1" : "0"); - } catch { - // 저장소가 막힌 환경 — 이번 화면에만 적용한다. - } - if (sketch) sketch.visible = on; - } - return sketch?.visible ?? false; - }; - scheduleTerrainClip(); - } - - return { - set, - setVisible(visible) { - corridorOn = visible; - applyCorridorVisibility(); - }, - setSurfaceVisible(visible) { - surfaceOn = visible; - applyCorridorVisibility(); - }, - resetTerrain() { - bandSplit?.dispose(); - bandSplit = null; - }, - rebuild() { - if (corridorBuild) set(corridorBuild); - }, - group: () => corridorGroup, - clipped: () => clippedTerrain, - dispose() { - offMode(); - corridorBuild = null; // 예약된 클립 콜백 무효화. - disposeCorridorGroup(); - disposeClippedTerrain(); - bandSplit?.dispose(); - bandSplit = null; - }, - }; -} diff --git a/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts b/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts deleted file mode 100644 index b5964869a..000000000 --- a/B05_Profile/Old/B05_Profile_UI_Viewer_Debug.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* ============================================================================= - * B05_Profile_UI_Viewer_Debug.ts - * 뷰어 **검증용 요약** — 코리도 빌드 결과를 `window.__corridorBuild` 에 얹는다 - * (`__corridorClip`·`__corridorScene` 과 같은 용도). - * - * `B05_Profile_UI_Viewer` 에서 그대로 떼어냈다(700줄 제한, 2026-09-04). 계산은 없고 - * 요약 형태만 있으므로 화면 동작에는 영향이 없다. - * ========================================================================== */ - -import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; - -/** - * 격자 재배치·투영선이 리본을 흔들었는지 화면 밖에서 수치로 확인한다(2026-08-26). - * `raw` 는 원본 참조라 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. - */ -export function setCorridorBuildSummary(build: CorridorBuildResult | null): void { - (window as unknown as { __corridorBuild?: unknown }).__corridorBuild = build - ? { - ribbons: build.ribbons.map((ribbon) => ({ - kind: ribbon.kind, - side: ribbon.side, - rows: ribbon.chainages.length, - cols: ribbon.colCount, - first: ribbon.chainages[0], - last: ribbon.chainages[ribbon.chainages.length - 1], - })), - outlineRows: build.outline.chainages.length, - outline: build.outline, - // 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28). - // 부재키(2026-09-04)도 함께 낸다 — 3D 개별 선택이 무엇을 집었는지 대조한다. - structures: (build.structures ?? []).map((solid) => ({ - kind: solid.kind, - key: solid.key ?? null, - at: solid.chainage_m, - rings: solid.rings?.length ?? 0, - points: solid.polygon?.length ?? 0, - })), - raw: { - ribbons: build.ribbons, - planCurves: build.planCurves ?? [], - cutWalls: build.cutWalls ?? [], - }, - planCurves: (build.planCurves ?? []).map((curve) => ({ - source: curve.source, - role: curve.role, - side: curve.side, - at: curve.setChainageM, - loops: curve.loops.length, - points: curve.loops.reduce((sum, loop) => sum + loop.length, 0), - z0: curve.loops[0]?.[0]?.[2] ?? null, - planZ: curve.planZ, - })), - } - : null; -} diff --git a/resources/tester/test_route_polyline_browser_mirror.py b/resources/tester/test_route_polyline_browser_mirror.py new file mode 100644 index 000000000..91218c038 --- /dev/null +++ b/resources/tester/test_route_polyline_browser_mirror.py @@ -0,0 +1,243 @@ +"""계획노선 편집 폴리라인 — 파이썬 ↔ TS 거울 비교 (계획서 0-2-A). + +`common_util/common_util_route_polyline.py` 의 **편집 갈래**(`simplify=False` + +`curve_flags`/`radii`)와 `B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts` 의 +`buildEditedPolyline` 은 한 벌이다. 한쪽만 고치면 **끌 때 보이는 선**(브라우저)과 +**[확인] 뒤 저장되는 선**(서버)이 갈린다. + +실행: ./venv/Scripts/python.exe -m pytest resources/tester/test_route_polyline_browser_mirror.py -q +""" + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402 + +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +MIN_RADIUS_M = 12.0 + +# (이름, 점, 곡선켬끔, 못박은 R) +CASES: list[tuple[str, list[tuple[float, float]], list[bool], list[float | None]]] = [ + ( + "직각 한 번 · R 자동", + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], + [True, True, True], + [None, None, None], + ), + ( + "지그재그 · 전부 곡선", + [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], + [True] * 5, + [None] * 5, + ), + ( + "가운데 곡선 지움", + [(0.0, 0.0), (80.0, 0.0), (120.0, 60.0), (200.0, 60.0), (240.0, 0.0)], + [True, True, False, True, True], + [None] * 5, + ), + ( + "R 못박음 30m", + [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], + [True, True, True], + [None, 30.0, None], + ), + ( + "R 이 접선 자리보다 커서 눌림", + [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0)], + [True, True, True], + [None, 500.0, None], + ), + ( + "완만한 꺾임(내각 큼)", + [(0.0, 0.0), (100.0, 0.0), (200.0, 12.0)], + [True, True, True], + [None, None, None], + ), + ( + "배향곡선처럼 되꺾임", + [(0.0, 0.0), (60.0, 0.0), (58.0, 30.0), (120.0, 30.0)], + [True] * 4, + [None] * 4, + ), + ( + "겹친 점(0.3m) 섞임 — 서버와 같이 버려야 함", + [(0.0, 0.0), (100.0, 0.0), (100.2, 0.2), (100.0, 100.0)], + [True] * 4, + [None] * 4, + ), +] + +DRIVER = """ +import { buildEditedPolyline } from "./B05_Profile_UI_RouteEdit_Curve.js"; +import fs from "node:fs"; +const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf-8")); +const out = cases.map((c) => + buildEditedPolyline(c.points, c.curveOn, c.curveRadius, c.minRadiusM), +); +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: + build_dir = tmp_path_factory.mktemp("ts_routeedit_build") + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Curve.ts"), + "--outDir", + str(build_dir), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + payload = [ + { + "points": [list(point) for point in points], + "curveOn": flags, + "curveRadius": radii, + "minRadiusM": MIN_RADIUS_M, + } + for _name, points, flags, radii in CASES + ] + cases_path = build_dir / "cases.json" + cases_path.write_text(json.dumps(payload), encoding="utf-8") + (build_dir / "driver.mjs").write_text(DRIVER, encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(build_dir / "driver.mjs"), str(cases_path)], + capture_output=True, + text=True, + cwd=str(build_dir), + ) + assert run.returncode == 0, f"node 실행 실패:\n{run.stdout}{run.stderr}" + return json.loads(run.stdout) + + +def _point_to_polyline_m(point: tuple[float, float], line: list[tuple[float, float]]) -> float: + """점에서 폴리라인까지 가장 가까운 거리(m).""" + best = float("inf") + for start, end in zip(line, line[1:]): + dx, dy = end[0] - start[0], end[1] - start[1] + span = dx * dx + dy * dy + t = ( + 0.0 + if span <= 0 + else max( + 0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / span) + ) + ) + best = min(best, math.hypot(start[0] + t * dx - point[0], start[1] + t * dy - point[1])) + return best + + +ARC_STEP_DEG = 5.0 + + +def _sampling_tolerance_m(radii: list[float]) -> float: + """원호 표본이 한 칸 어긋났을 때 생길 수 있는 최대 벌어짐(m) — 현(弦)의 중앙종거. + + 원호 위 점을 **몇 개** 찍을지는 `int(도수/5)` 라 90° 처럼 딱 떨어지는 자리에서 개수가 + 하나 갈릴 수 있다 — `math.sin` 이 파이썬(C libm)과 노드(V8)에서 마지막 자리 하나가 달라 + 중심이 1e-14 m 어긋나고 그것이 정수 나눗셈 경계를 넘긴다(2026-09-07 실측: + 89.99999999999992 vs 90.0). **같은 원 위의 같은 호**라 선의 모양은 같지만, 한쪽 점이 + 다른 쪽 현의 가운데에 놓이면 R(1−cos(스텝/2)) 만큼 떨어져 보인다. 표본 개수가 갈리면 + 상대 쪽 현이 한 칸 넓어지므로 **현 하나분**(R(1−cos(5°)))을 문턱으로 잡는다. + 공식이 어긋나면 점이 **미터 단위**로 움직이므로 이 문턱으로도 충분히 걸린다. + """ + if not radii: + return 1e-9 + return max(radii) * (1 - math.cos(math.radians(ARC_STEP_DEG))) + 1e-9 + + +@pytest.mark.parametrize("index", range(len(CASES))) +def test_python_and_ts_draw_the_same_line(ts_result, index: int) -> None: + name, points, flags, radii = CASES[index] + expected = build_planned_polyline( + list(points), + min_radius_m=MIN_RADIUS_M, + simplify=False, + curve_flags=list(flags), + radii=list(radii), + ) + actual = ts_result[index] + + got = [(x, y) for x, y in actual["vertices"]] + assert abs(len(got) - len(expected.vertices)) <= 1, f"[{name}] 정점 개수가 크게 다름" + # 끝점은 정확히 같아야 한다 — 노선의 시작·끝은 표본 간격과 무관하다. + for axis in (0, 1): + assert abs(got[0][axis] - expected.vertices[0][axis]) < 1e-9, f"[{name}] 시작점이 다름" + assert abs(got[-1][axis] - expected.vertices[-1][axis]) < 1e-9, f"[{name}] 끝점이 다름" + + if len(got) == len(expected.vertices): + # 표본 개수가 같으면 **자리까지 똑같아야** 한다 — 여기서 봐주면 시험이 헐거워진다. + for (px, py), (tx, ty) in zip(expected.vertices, got): + assert abs(px - tx) < 1e-9, f"[{name}] x 가 다름" + assert abs(py - ty) < 1e-9, f"[{name}] y 가 다름" + else: + tolerance = _sampling_tolerance_m([curve.radius_m for curve in expected.curves]) + for vertex in got: + assert _point_to_polyline_m(vertex, expected.vertices) < tolerance, ( + f"[{name}] TS 정점이 서버 선에서 벗어남" + ) + for vertex in expected.vertices: + assert _point_to_polyline_m(vertex, got) < tolerance, ( + f"[{name}] 서버 정점이 TS 선에서 벗어남" + ) + + assert len(actual["curves"]) == len(expected.curves), f"[{name}] 곡선 개수가 다름" + for want, got in zip(expected.curves, actual["curves"]): + assert abs(want.radius_m - got["radius_m"]) < 1e-9, f"[{name}] 반지름이 다름" + assert abs(want.tangent_m - got["tangent_m"]) < 1e-9, f"[{name}] 접선 길이가 다름" + assert abs(want.inner_angle_deg - got["inner_angle_deg"]) < 1e-9, f"[{name}] 내각이 다름" + for axis in (0, 1): + assert abs(want.apex[axis] - got["apex"][axis]) < 1e-9, f"[{name}] 교각점이 다름" + assert abs(want.start[axis] - got["start"][axis]) < 1e-9, f"[{name}] 곡선 시작이 다름" + assert abs(want.end[axis] - got["end"][axis]) < 1e-9, f"[{name}] 곡선 끝이 다름" + assert want.violations == got["violations"], f"[{name}] 위반 문구가 다름" + + +def test_ts_reports_curve_seats_in_the_caller_index(ts_result) -> None: + """겹친 점을 버려도 TS 가 돌려주는 자리 번호는 **넘긴 목록 기준**이어야 한다. + + 화면이 잡고 있는 배열은 사용자가 끄는 노드 목록 그대로다. 서버처럼 걸러낸 뒤 번호를 + 돌려주면 손잡이가 엉뚱한 노드에 붙는다. + """ + index = next(i for i, case in enumerate(CASES) if "겹친 점" in case[0]) + _name, points, _flags, _radii = CASES[index] + curves = ts_result[index]["curves"] + assert len(curves) == 1, "겹친 점을 버리면 꺾임점은 하나만 남는다" + seat = curves[0]["node_first"] + # 버려진 것은 index 2 (100.2, 0.2) 이므로 곡선이 앉는 자리는 원래 목록의 1번이다. + assert seat == 1 + assert points[seat] == (100.0, 0.0) + assert len(ts_result[index]["nodes"]) == len(points), "노드 요약은 넘긴 목록과 자리가 같다" + + +def test_turning_a_curve_off_leaves_a_sharp_corner(ts_result) -> None: + """곡선을 지운 자리는 원호 없이 꺾여야 한다 — 화면에서 눈으로 확인하는 것과 같은 값.""" + on_index = next(i for i, case in enumerate(CASES) if case[0] == "지그재그 · 전부 곡선") + off_index = next(i for i, case in enumerate(CASES) if case[0] == "가운데 곡선 지움") + assert len(ts_result[on_index]["curves"]) == 3 + assert len(ts_result[off_index]["curves"]) == 2 + # 지운 자리(120, 60)가 폴리라인에 **꼭짓점 그대로** 남는다. + assert [120.0, 60.0] in ts_result[off_index]["vertices"] + assert [120.0, 60.0] not in ts_result[on_index]["vertices"] diff --git a/tsconfig.json b/tsconfig.json index 746b2b900..30bf838b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,5 +35,5 @@ } }, "include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"], - "exclude": ["0_old", "B05_Profile/Old", "node_modules", "venv", "dist"] + "exclude": ["0_old", "node_modules", "venv", "dist"] }