chore(B05 정리): 342-4 B05_Profile/Old 27파일 지움 · 노선 거울 시험 resources/tester 로
- Old/ 부르는 곳 0(주석만) · tsconfig exclude 줄 정리 · typecheck 통과 - test_route_polyline_browser_mirror.py — tmp/tests 에서 옮김 · 빌드 자리 = pytest 임시 폴더 · 10 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -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<string, CacheEntry>();
|
||||
|
||||
function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
|
||||
async function requestCorridor(path: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */
|
||||
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<StoredLookup> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코리도 준비 — 캐시/저장본/빌드 순으로 확보해 반환.
|
||||
* 반환된 build는 뷰어 setCorridor가 심 보정으로 제자리 수정할 수 있다(직렬화는
|
||||
* saveCorridorIfDirty 시점의 최신 상태를 담는다).
|
||||
*/
|
||||
/** 종단 계획선 샘플(라이브 편집 반영분) — 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<CorridorBuildResult | null> {
|
||||
const key = keyOf(projectId, routeId);
|
||||
const hash = corridorHash(detail, routePoints);
|
||||
const cached = cache.get(key);
|
||||
if (cached && cached.hash === hash) return cached.build;
|
||||
|
||||
const 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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
@@ -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<CrossSection>): 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<StructureSpanRange>,
|
||||
): 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<string>();
|
||||
stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key)));
|
||||
|
||||
const rows: Array<{
|
||||
chainage_m: number;
|
||||
center: XY;
|
||||
left: XY;
|
||||
sections: Map<string, OffsetPoint[]>;
|
||||
outerLeft: OffsetPoint;
|
||||
outerRight: OffsetPoint;
|
||||
/** 그 행 지반선 — 성토 리본에 원지반 표고를 함께 실어 절단면 측벽의 밑선으로 쓴다. */
|
||||
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<string, PieceControl[]>();
|
||||
(["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<string, OffsetPoint[]>();
|
||||
/** 이 행에서 기슭막이 구간이라 지반 트림을 생략할 비탈 키. */
|
||||
const fixedSlopeKeys = new Set<string>();
|
||||
/** 구조물 측점 단면을 그대로 쓴 조각 → 그 측점의 누가거리(종단 보정 기준). */
|
||||
const fixedSource = new Map<string, number>();
|
||||
/** 제어점 보간으로 이미 계획고에 얹은 조각 — 종단 보정을 두 번 하지 않게 표시. */
|
||||
const controlPlaced = new Set<string>();
|
||||
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 : [],
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<readonly [number, number]>,
|
||||
): [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<Array<[PXY, PXY]>> = [];
|
||||
|
||||
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<CorridorStructure>,
|
||||
kinds?: ReadonlySet<CorridorStructure["kind"]>,
|
||||
): 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<CorridorStructure>): 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<Array<[number, number, number]>> {
|
||||
const loops: Array<Array<[number, number, number]>> = [];
|
||||
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<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
profileZ: ((chainageM: number) => number | null) | null,
|
||||
planCurves: ReadonlyArray<PlanCurve> = [],
|
||||
): 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;
|
||||
}
|
||||
@@ -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<PlanCurve>,
|
||||
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<PlanCurve>,
|
||||
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<PlanCurve>,
|
||||
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),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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<number>;
|
||||
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<PlanRegion>, p: P2, sceneY?: number): boolean {
|
||||
return regions.some((region) => region.contains(p, sceneY));
|
||||
}
|
||||
|
||||
function segmentsNearAny(
|
||||
regions: ReadonlyArray<PlanRegion>,
|
||||
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<number> }
|
||||
).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<PlanRegion>,
|
||||
): 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<number>, 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<PlanRegion>,
|
||||
): 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<number>)[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<Array<[number, number]>> = [];
|
||||
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;
|
||||
}
|
||||
@@ -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<CurveArea>, ring: ReadonlyArray<Corner>): 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<CurveArea>,
|
||||
ring: ReadonlyArray<Corner>,
|
||||
/** 남길 쪽 — 기본은 커브 **밖**(기본 성토면). 패치는 **안**을 남긴다. */
|
||||
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<CurveArea>, ring: ReadonlyArray<Corner>): 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<PlanCurve["source"]> = ["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<Array<[number, number]>> {
|
||||
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<number, Array<[number, number]>>();
|
||||
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<Array<[number, number]>> = [];
|
||||
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<ReadonlyArray<[number, number, number]>>,
|
||||
): 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<CorridorRibbon>,
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
): { ribbons: CorridorRibbon[]; curves: PlanCurve[]; walls: CorridorCutWall[] } {
|
||||
const areasBySide = new Map<string, CurveArea[]>();
|
||||
const planesBySide = new Map<string, SetPlane[]>();
|
||||
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<string, Array<Array<[number, number, number]>>>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<OffsetPoint>,
|
||||
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<string, number> = { 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<CrossSection>,
|
||||
slopes: ReadonlyArray<StructureSlope> = [],
|
||||
): 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<string, PieceControl[] | null>();
|
||||
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<number>,
|
||||
raw: ArrayLike<number>,
|
||||
open: ArrayLike<number>,
|
||||
station: ArrayLike<number>,
|
||||
shaped: ArrayLike<number>,
|
||||
): 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<DeformLimit> = [],
|
||||
): 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<DeformProfile | null> = new Array(n * 4).fill(null);
|
||||
const catches = new Float64Array(n * 4);
|
||||
const heights = new Float64Array(n * 4);
|
||||
const bends: Array<Array<[number, number]>> = 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 = <T>(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];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Breakline>;
|
||||
/** 지울 자리(노면부 · 구조물 구멍). */
|
||||
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<number, number[]>();
|
||||
private minX = Infinity;
|
||||
private minZ = Infinity;
|
||||
private cols = 1;
|
||||
|
||||
constructor(lines: ReadonlyArray<Breakline>) {
|
||||
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<P2>): 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<P2>, 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<number>,
|
||||
segs: ReadonlyArray<number>,
|
||||
): 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<Set<number>> = [];
|
||||
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<Array<{ w: number; id: number }>> = [[], [], []];
|
||||
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<number>();
|
||||
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<number> }> = [];
|
||||
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<P2>, 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<number>;
|
||||
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<number> }
|
||||
).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<number>, 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 };
|
||||
}
|
||||
@@ -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>): 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<OffsetPoint>,
|
||||
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<CrossSection>): 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>,
|
||||
): 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<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): Array<Array<[number, number]>> {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
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<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): 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<DeformSide, number>();
|
||||
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<CorridorStructure>,
|
||||
): (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<string, DrapeSpan[]>();
|
||||
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<CorridorStructure>,
|
||||
groundAfter: (x: number, y: number) => number | null,
|
||||
): Array<Array<[number, number]>> {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
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;
|
||||
}
|
||||
@@ -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<DeformRow>, 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<number, P2[]>();
|
||||
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<number>();
|
||||
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<P2[]>,
|
||||
): (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<P2>, 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 };
|
||||
}
|
||||
@@ -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<string | number> = [`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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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<CorridorBuildResult, LegacyInputs>();
|
||||
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;
|
||||
}
|
||||
@@ -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<CorridorKind, number> = {
|
||||
carriageway: 0x3f4451,
|
||||
shoulder: 0x9aa2b1,
|
||||
ditch: 0x38bdf8,
|
||||
cut: 0xb5179e,
|
||||
fill: 0x06b6d4,
|
||||
};
|
||||
|
||||
/** 탑뷰 투영 커브 색(2026-08-26 사용자) — 종류별. 지형·코리도 색과 안 겹친다. */
|
||||
const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
|
||||
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<Record<CorridorKind, 1 | -1>> = { cut: 1, fill: -1 };
|
||||
|
||||
/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */
|
||||
const POLYGON_OFFSET_FACTOR = -1;
|
||||
|
||||
/** 빗금 텍스처 캐시 — 리본마다 캔버스를 새로 그리면 편집 반복 시 낭비다. */
|
||||
const hatchCache = new Map<number, THREE.Texture>();
|
||||
|
||||
/** 대각 빗금 캔버스 텍스처(반복). 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<CorridorStructure["kind"], number> = {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Array<[number, number, number]>>;
|
||||
/**
|
||||
* 가로 방향이 **노선이 아니라 구조물 축에 고정**된 커브(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<CrossSection>,
|
||||
): (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<CorridorStructure>): 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<Array<[number, number, number]>> {
|
||||
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<CorridorRibbonLike>): SurfaceLookup {
|
||||
/**
|
||||
* `측(side)|누가거리` → 그 행의 [x, y, z] 열 목록. 절·성토만 담고, 언제나
|
||||
* **안쪽(도로) → 바깥(끝선)** 순으로 뒤집어 담는다.
|
||||
*
|
||||
* 비탈 조각은 편거리 오름차순으로 저장된다. 좌표 규약이 `+offset = 좌측`이라
|
||||
* 좌측은 안쪽→바깥이지만 **우측은 바깥→안쪽**이 된다(2026-08-26 실측, 세월교
|
||||
* 149.73 우측: 0열이 차도 끝에서 5.97m, 마지막 열이 0m). 방향을 여기서 한 번
|
||||
* 맞춰 두지 않으면 안쪽 끝 조회도, 수직면 무승부 처리도 우측에서 뒤집힌다.
|
||||
*/
|
||||
const rowsAt = new Map<string, Array<Array<[number, number, number]>>>();
|
||||
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<number>;
|
||||
positions: ArrayLike<number>;
|
||||
}
|
||||
|
||||
/** 투영 점을 리본 정점으로 붙여 보는 허용오차(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<ReadonlyArray<[number, number]>>,
|
||||
): Array<Array<[number, number, number]>> {
|
||||
const lines: Array<Array<[number, number, number]>> = [];
|
||||
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<Array<[number, number, number]>> {
|
||||
return assembleStripLoops(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브. 세트 = 같은 측점이 소유한 구조물 묶음
|
||||
* (기슭막이 + 집수정 + 배관, 또는 BOX암거·세월교 한 벌).
|
||||
*/
|
||||
export function buildPlanCurves(
|
||||
crossSections: ReadonlyArray<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
outline: CorridorOutline,
|
||||
/** 절·성토 리본 — 투영선 표고를 여기서 읽는다. 없으면 투영선을 안 낸다. */
|
||||
ribbons: ReadonlyArray<CorridorRibbonLike> = [],
|
||||
): 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<Array<[number, number, number]>>,
|
||||
): 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<Side, Array<Array<[number, number]>>>();
|
||||
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;
|
||||
}
|
||||
@@ -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<readonly [number, number]> | 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<Row>,
|
||||
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<StructureFrame>, 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<AxisRow>, 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<CorridorStructure>,
|
||||
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<number, StructureFrame>();
|
||||
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<CorridorStructure>,
|
||||
side: Side,
|
||||
roadEdgeOffset: number,
|
||||
planZ: number,
|
||||
axis: StationAxis,
|
||||
): Array<Array<[number, number, number]>> {
|
||||
// 아령 외곽선을 낼 수 있는 세트(BOX암거)는 **그 한 줄만** 낸다 — 부재별 고리를
|
||||
// 함께 내면 날개 패널 슬리버가 다시 섞여 지형 재노출에 구멍을 만든다.
|
||||
// 저판·측벽은 상판 폭 안, 날개는 에이프런 안이라 외곽선이 전부 품는다.
|
||||
const outline = outlineLoop(set, side, roadEdgeOffset, planZ, axis);
|
||||
if (outline) return [outline];
|
||||
const loops: Array<Array<[number, number, number]>> = [];
|
||||
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<readonly [number, number]>,
|
||||
): 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<CorridorStructure>,
|
||||
side: Side,
|
||||
planZ: number,
|
||||
axis: StationAxis,
|
||||
toe: ReadonlyArray<readonly [number, number]>,
|
||||
): {
|
||||
loops: Array<Array<[number, number, number]>>;
|
||||
/** 측벽이 설 자리 — [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점]. 성토 끝선 구간은
|
||||
* 뺀다(거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). */
|
||||
walls: Array<Array<[number, number, number]>>;
|
||||
} {
|
||||
if (toe.length < 2) return { loops: [], walls: [] };
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const loops: Array<Array<[number, number, number]>> = [];
|
||||
const walls: Array<Array<[number, number, number]>> = [];
|
||||
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 };
|
||||
}
|
||||
@@ -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<number>();
|
||||
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>): 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<ReadonlyArray<readonly [number, number]>>, 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<PlanRegion>,
|
||||
) {}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<CorridorRibbon>,
|
||||
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<typeof current>): 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 };
|
||||
}
|
||||
@@ -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<CorridorKind, number> = {
|
||||
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<string, OffsetPoint[]>;
|
||||
/** 축퇴 기준점 — 좌/우 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<string, OffsetPoint[]>;
|
||||
/**
|
||||
* 절·성토 비탈을 **비탈 시작점(도로부 바깥 끝) 기준 상대 좌표**로도 들고 있는다.
|
||||
*
|
||||
* 측구와 같은 규칙(도로부 한 몸 제어) — 행마다 도로부 끝 앵커에 얹으면 경사
|
||||
* 형상(1:1.2 등)이 그대로 유지된 채 위치만 따라온다(2026-08-23 완전 분리).
|
||||
*/
|
||||
slopeRelative: Map<string, OffsetPoint[]>;
|
||||
/** 지반선 보간기 — 행 지반선(두 측점 혼합) 계산용. 샘플 부실이면 null. */
|
||||
ground: ((offset: number) => number) | null;
|
||||
/**
|
||||
* 배수관 세트 기슭막이의 종방향 점유 구간(2026-08-23 사용자 — 성토부 형태 변경).
|
||||
* 이 구간 안 행은 그 측의 비탈을 이 측점의 트림 사면(노견 연장 + 벽 상단 종결)
|
||||
* 그대로 쓰고 **지반 트림을 하지 않는다** — 벽에서 끝난 사면을 지반까지 도로
|
||||
* 늘리면 안 된다.
|
||||
*/
|
||||
wallSpans?: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>>;
|
||||
/**
|
||||
* 노견 확장 구간(2026-08-25 사용자 ①) — 구조물 실루엣의 수평 확장점까지 넓힌
|
||||
* 노견을 구조물 구간 내내 유지하기 위한 제어점 범위. 비탈 교체(wallSpans)와
|
||||
* 달리 **노견 조각에만** 걸린다.
|
||||
*/
|
||||
shoulderSpans?: Partial<Record<"left" | "right", { beforeM: number; afterM: number }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 분류 선택(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<string, OffsetPoint[]>,
|
||||
roadEdge: { left: OffsetPoint; right: OffsetPoint },
|
||||
): Map<string, OffsetPoint[]> {
|
||||
const relative = new Map<string, OffsetPoint[]>();
|
||||
(["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<string, OffsetPoint[]>();
|
||||
const put = (kind: CorridorKind, side: CorridorSide, a: number, b: number): void => {
|
||||
const sub = slicePolyline(sorted, a, b);
|
||||
if (sub.length >= 2) pieces.set(pieceKey(kind, side), resample(sub, PIECE_COLS[kind]));
|
||||
};
|
||||
|
||||
put("carriageway", "center", cwR, cwL);
|
||||
put("shoulder", "left", cwL, roadL);
|
||||
put("shoulder", "right", roadR, cwR);
|
||||
// 좌측: road edge → (측구) → 비탈끝. 우측은 대칭(음수 방향).
|
||||
let slopeStartL = roadL;
|
||||
let slopeStartR = roadR;
|
||||
if (ditchWidth > 0) {
|
||||
if (ditchSide === "left") {
|
||||
put("ditch", "left", roadL, roadL + ditchWidth);
|
||||
slopeStartL = roadL + ditchWidth;
|
||||
} else {
|
||||
put("ditch", "right", roadR - ditchWidth, roadR);
|
||||
slopeStartR = roadR - ditchWidth;
|
||||
}
|
||||
// 측구 바깥(노견 반대측) 상단은 **노견과 같은 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<string, OffsetPoint[]>();
|
||||
(["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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<Array<[number, number]>>;
|
||||
/** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */
|
||||
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<CorridorStructure>,
|
||||
): Array<Array<[number, number, number]>> {
|
||||
const loops: Array<Array<[number, number, number]>> = [];
|
||||
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<DeformLimit>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
stationChainages: ReadonlyArray<number>,
|
||||
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<number, [number, number]> } | 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<number, [number, number]>();
|
||||
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<Array<[number, number]>> = [];
|
||||
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 };
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<RoutePointKind, string> = {
|
||||
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<typeof createRouteCalcPart>;
|
||||
|
||||
registerPanelSection({
|
||||
id: "route-calc",
|
||||
title: "경로 계산 설정",
|
||||
views: ["profile"],
|
||||
order: 40,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "경로 계산 설정", true);
|
||||
frame.body.append(...part<RouteCalcPart>(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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user