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:
2026-10-09 14:56:48 +09:00
co-authored by Claude Opus 5.5
parent 5d774ad6d7
commit 87ffe800ed
24 changed files with 1039 additions and 379 deletions
+1 -1
View File
@@ -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 };
+4 -73
View File
@@ -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),
})),
};
}
+2 -138
View File
@@ -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;
+1 -1
View File
@@ -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);
/** 원지반 색을 못 찾은 점(지형 밖) — 회색. */
+1 -25
View File
@@ -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();
}
+1 -15
View File
@@ -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;
}