refactor(b05): 지금 방식 꼬리 · 평면 스케치 파일을 Old 로(135-1 나)
- 통째 이동 = _Plan · _Plan_Footprint · _Cut · _Carve · _Carve_Wing · _Union · _Skirt - 잘라 냄(원본은 Old 같은 이름) = _Build(스케치 · 자르기 · 패치 꼬리 · 깃발 · 측벽 지반 표고) · _Build_Types · _Envelope · _Mesh(patch · cellMask · trimTris · cutWalls · planCurves) · 뷰어 디버그 · 뷰어코리도(__corridorPlanCurves) - BUILD_VERSION · 행 자리 그대로 — 노면 · 구조물 · 외곽 같음(옛 저장본 남는 몫은 무시) - 시험 2개 + 도우미 2 = resources/tester/old/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
@@ -145,7 +145,7 @@ export type ProfileSamples = Array<{
|
||||
|
||||
/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나.
|
||||
* 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다
|
||||
* (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */
|
||||
* (`__corridorScene`·`__corridorClip`와 같은 디버그 훅). */
|
||||
declare global {
|
||||
interface Window {
|
||||
__corridorSource?: { source: "stored" | "built"; hash: string; at: number };
|
||||
|
||||
@@ -50,26 +50,9 @@ import type {
|
||||
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까지 쪼갤 이유가 없어졌다 —
|
||||
@@ -84,16 +67,8 @@ const SUBDIVIDE_STEP_M = 0.2;
|
||||
const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M;
|
||||
|
||||
/**
|
||||
* 투영 커브 안쪽 **성토면 도려내기** 적용 여부(2026-08-26 사용자). 끄면 커브만 얹고
|
||||
* 서피스는 그대로 둔다. 절토면은 대상이 아니다(사용자: 성토면만).
|
||||
*/
|
||||
const FILL_CUT_APPLY: boolean = true;
|
||||
|
||||
/**
|
||||
* 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영
|
||||
* 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은
|
||||
* 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시
|
||||
* 셀 중간에 뜬다.
|
||||
* 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**한다(옛 탑뷰 투영
|
||||
* 커브용 · 행 자리가 저장본 판번호에 묶여 그대로 둠). 출처 = `structureSilhouettes`의 전/후 몫.
|
||||
*/
|
||||
interface StructureSpanRange {
|
||||
side: CorridorSide;
|
||||
@@ -200,8 +175,6 @@ export function buildCorridor(
|
||||
sections: Map<string, OffsetPoint[]>;
|
||||
outerLeft: OffsetPoint;
|
||||
outerRight: OffsetPoint;
|
||||
/** 그 행 지반선 — 성토 리본에 원지반 표고를 함께 실어 절단면 측벽의 밑선으로 쓴다. */
|
||||
ground: ((offset: number) => number) | null;
|
||||
}> = [];
|
||||
|
||||
const toModel = (row: (typeof rows)[number], point: OffsetPoint): [number, number, number] => [
|
||||
@@ -497,7 +470,6 @@ export function buildCorridor(
|
||||
sections,
|
||||
outerLeft: outerOf("left"),
|
||||
outerRight: outerOf("right"),
|
||||
ground: groundRow,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -535,10 +507,7 @@ export function buildCorridor(
|
||||
}
|
||||
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) => {
|
||||
@@ -547,14 +516,9 @@ export function buildCorridor(
|
||||
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 } : {}) });
|
||||
ribbons.push({ kind, side, colCount, chainages, positions });
|
||||
// 측구 리본이 노선 중간에서 시작·종료하면 그 자리를 벽으로 막는다.
|
||||
if (kind === "ditch") {
|
||||
if (start > 0) pushDitchCap(rows[start], key, side);
|
||||
@@ -590,38 +554,5 @@ export function buildCorridor(
|
||||
});
|
||||
if (points.length >= 2) caps.push({ points });
|
||||
});
|
||||
// 탑뷰 투영 커브(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 : [],
|
||||
};
|
||||
return { ribbons, outline, caps, structures };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Build_Types.ts
|
||||
* 코리도 빌더가 내는 **결과 자료형** — 리본·절취 측벽·캡·빌드 결과.
|
||||
* 코리도 빌더가 내는 **결과 자료형** — 리본·캡·빌드 결과.
|
||||
*
|
||||
* `B05_Profile_UI_Corridor_Build` 에서 떼어냈다(700줄 제한, 2026-09-02). 계산은 전혀
|
||||
* 없고 모양과 그 뜻(주석)만 있다. 빌더가 그대로 다시 내보내므로 기존 import 경로
|
||||
@@ -10,25 +10,12 @@
|
||||
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 (모델 좌표).
|
||||
*
|
||||
@@ -37,42 +24,6 @@ export interface CorridorRibbon {
|
||||
* 생겼다(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;
|
||||
}
|
||||
|
||||
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
|
||||
@@ -89,14 +40,6 @@ export interface CorridorBuildResult {
|
||||
caps: CorridorCap[];
|
||||
/** 배수관 세트 구조물(기슭막이·집수정·배관) 솔리드(2026-08-23). 없으면 빈 배열. */
|
||||
structures: CorridorStructure[];
|
||||
/**
|
||||
* 구조물 세트별 **탑뷰 투영 커브**(2026-08-26 사용자) — 세트 구조물 상단 5m
|
||||
* 평면에 눕힌 닫힌 고리들. 구조물·비탈(변형/원본)을 유입·유출로 나눠 담는다.
|
||||
* 저장 코리도엔 없을 수 있다.
|
||||
*/
|
||||
planCurves?: PlanCurve[];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 저장본에도 담는다. 없으면 빈 배열. */
|
||||
cutWalls?: CorridorCutWall[];
|
||||
/** 측점 단면 — 새 방식 지형 변형이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */
|
||||
/** 측점 단면 — 제안 ③ 사면이 비탈 단면을 읽는다(115-1). 메모리만 · 저장본에 안 담는다. */
|
||||
sections?: CrossSection[];
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function roadRows(build: CorridorBuildResult): DeformRow[] {
|
||||
>();
|
||||
for (const ribbon of build.ribbons) {
|
||||
const rank = RANK[ribbon.kind];
|
||||
if (!rank || ribbon.patch) continue;
|
||||
if (!rank) continue;
|
||||
const cols = ribbon.colCount;
|
||||
ribbon.chainages.forEach((chainage, row) => {
|
||||
const key = Math.round(chainage * 1000);
|
||||
|
||||
@@ -37,34 +37,12 @@ export interface CorridorEnvelope {
|
||||
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 — 설계 입력 요약 문자열의 버전 해시. */
|
||||
@@ -247,22 +225,6 @@ function base64FromFloats(values: Float64Array): string {
|
||||
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);
|
||||
@@ -280,24 +242,10 @@ export function serialize(build: CorridorBuildResult, hash: string): CorridorEnv
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,23 +257,9 @@ export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import type {
|
||||
CorridorKind,
|
||||
CorridorRibbon,
|
||||
CorridorStructure,
|
||||
PlanCurve,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
/**
|
||||
@@ -35,44 +34,6 @@ const KIND_COLORS: Record<CorridorKind, number> = {
|
||||
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;
|
||||
|
||||
@@ -157,12 +118,8 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -170,27 +127,9 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff
|
||||
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.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
@@ -408,81 +347,6 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
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;
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
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);
|
||||
/** 원지반 색을 못 찾은 점(지형 밖) — 회색. */
|
||||
|
||||
@@ -11,11 +11,7 @@
|
||||
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 { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
|
||||
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";
|
||||
@@ -101,8 +97,6 @@ function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
|
||||
return {
|
||||
...build,
|
||||
ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"),
|
||||
planCurves: [],
|
||||
cutWalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,24 +232,6 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -35,21 +35,7 @@ export function setCorridorBuildSummary(build: CorridorBuildResult | null): void
|
||||
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,
|
||||
})),
|
||||
raw: { ribbons: build.ribbons },
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* =============================================================================
|
||||
* 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[];
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/* =============================================================================
|
||||
* 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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* =============================================================================
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user