feat(B05): 변형 성토면을 지반 교차선에서 끊고 구조물 자리 원지반을 되살린다
- 패치(변형 성토면) 바깥 끝 스커트 신설(_Corridor_Skirt.ts): 지형 메시 높이를 밑선으로 성토면 → 원지반 −1m까지 판을 세운다. 성토 빗금 메시 서피스로 그린다 (절취 측벽은 종전대로 일반 서피스). 소유 측점 행에서 지반에 닿는 줄만 대상 (허용 0.5m) — 벽 뒷면·구체 상단에서 끝나는 줄은 제외해 BOX암거 앞면에 없던 서피스가 생기던 회귀를 막는다. - 패치 행을 그 행 지반선과 직접 교차시켜 자르고 연장한다(trimSlopeToGround). - 날개 사다리꼴 몫만큼 패치 행 범위를 넓히고(wingSpanOf), 날개벽 평면 스케치는 트림하지 않는다. BOX암거는 행 가로 방향을 기준 측점 좌향으로 고정한다. - 구조물 안착 자리 원지반 재노출: 원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)를 코리도 스트립에서 빼 지형을 남긴다(PlanLoopRegion·SubtractedRegion 신설). - BUILD_VERSION 63 → 67: 저장본을 만료시켜야 기하 수정이 화면에 반영된다. 자체검증: 스커트 3리본 56판(최대 낙차 0.92m), 원지반 +2,104 삼각형, tmp/tests 213 passed, typecheck·prettier 통과. 남은 작업: 세월교 날개 사다리꼴 채우기(날개 축 스윕) — 다음 세션. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,7 +108,17 @@ function fnv1a(text: string): string {
|
||||
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
|
||||
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
|
||||
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
|
||||
const BUILD_VERSION = 63;
|
||||
// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) +
|
||||
// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정),
|
||||
// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토)
|
||||
// (2026-08-27 사용자).
|
||||
// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = 날개 구간 행은 벽 상단에서 설계 성토 물매로 지반까지 내려 긋는다 — 날개
|
||||
// 사다리꼴을 변형 성토면이 채운다(2026-08-27 사용자).
|
||||
const BUILD_VERSION = 67;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
|
||||
@@ -97,6 +97,15 @@ export interface CorridorRibbon {
|
||||
export interface CorridorCutWall {
|
||||
side: "left" | "right";
|
||||
positions: Float64Array;
|
||||
/** 패치 바깥 끝 스커트인가(2026-08-27) — 뷰어가 지형 색인으로 뒤늦게 붙이므로,
|
||||
* 다시 붙일 때 옛 것을 걷어내려면 표식이 있어야 한다(`_Corridor_Skirt.ts`). */
|
||||
patchSkirt?: boolean;
|
||||
/**
|
||||
* 정점별 UV(정점당 2) — **빗금 있는 성토 서피스**로 그릴 때만 싣는다.
|
||||
* 절취 측벽은 일반 서피스(빗금 없음)이고, 패치 스커트는 성토면의 연장이라 빗금을
|
||||
* 넣는다(2026-08-27 사용자: "변형 성토면 하단 끝단부 서피스는 메시 형태").
|
||||
*/
|
||||
uvs?: Float32Array;
|
||||
}
|
||||
|
||||
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
|
||||
@@ -669,7 +678,7 @@ export function buildCorridor(
|
||||
// 성토 패치는 **투영 커브·절취를 다 낸 뒤** 붙인다 — 먼저 넣으면 투영선이 패치
|
||||
// 표면에서 z를 읽어 절취 경계가 달라지고, 절취가 패치를 다시 갉는다.
|
||||
const patchRibbons = FILL_PATCH_APPLY
|
||||
? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null)
|
||||
? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null, planCurves)
|
||||
: [];
|
||||
return {
|
||||
ribbons: [...(cut ? cut.ribbons : ribbons), ...patchRibbons],
|
||||
|
||||
@@ -29,10 +29,12 @@ import {
|
||||
cutInnerTo,
|
||||
PIECE_COLS,
|
||||
resample,
|
||||
trimSlopeToGround,
|
||||
type CorridorKind,
|
||||
type OffsetPoint,
|
||||
} from "./B05_Profile_UI_Corridor_Station";
|
||||
import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { buildGroundAt, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
|
||||
import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union";
|
||||
import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union";
|
||||
@@ -40,6 +42,10 @@ import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_
|
||||
/** 풋프린트가 노견보다 이만큼은 밖으로 나가야 자를 값어치가 있다(m). */
|
||||
const MIN_REACH_M = 0.05;
|
||||
|
||||
/** 패치 줄이 "지반에서 끝나는 줄"로 볼 허용 낙차(m) — 소유 측점 바깥 끝에서 잰다.
|
||||
* 스커트 게이트(`_Corridor_Skirt.ts` PATCH_TOE_TOLERANCE_M)와 같은 값·같은 이유다. */
|
||||
const PATCH_TRIM_TOLERANCE_M = 0.5;
|
||||
|
||||
/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다.
|
||||
* 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */
|
||||
const NEAR_LIMIT_M = 30;
|
||||
@@ -214,6 +220,41 @@ export function assembleStripLoops(
|
||||
return loops;
|
||||
}
|
||||
|
||||
/**
|
||||
* 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②).
|
||||
*
|
||||
* 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts`
|
||||
* `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는
|
||||
* 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다.
|
||||
*
|
||||
* 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라
|
||||
* 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유).
|
||||
*/
|
||||
function wingSpanOf(
|
||||
curves: ReadonlyArray<PlanCurve>,
|
||||
chainageM: number,
|
||||
side: "left" | "right",
|
||||
frame: RouteFrame,
|
||||
): { beforeM: number; afterM: number } {
|
||||
// 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다.
|
||||
const forwardX = -frame.leftY;
|
||||
const forwardY = frame.leftX;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
for (const curve of curves) {
|
||||
if (curve.source !== "wing-box" || curve.side !== side) continue;
|
||||
if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue;
|
||||
for (const loop of curve.loops) {
|
||||
for (const [x, y] of loop) {
|
||||
const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY;
|
||||
if (s < 0) before = Math.max(before, -s);
|
||||
else after = Math.max(after, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { beforeM: before, afterM: after };
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된
|
||||
* 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을
|
||||
@@ -229,11 +270,34 @@ export function buildStructurePatchRibbons(
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
frameAt: (chainageM: number) => RouteFrame | null,
|
||||
profileZ: ((chainageM: number) => number | null) | null,
|
||||
planCurves: ReadonlyArray<PlanCurve> = [],
|
||||
): CorridorRibbon[] {
|
||||
const ribbons: CorridorRibbon[] = [];
|
||||
const revets = buildRevetFootprint(structures);
|
||||
// 행별 지반선 — 패치를 행마다 지반 교차선에서 끝내는 데 쓴다(투영 커브
|
||||
// `slope-deformed`와 같은 출처·같은 보간이라 빨간 교차선과 정확히 맞는다).
|
||||
const groundOfChainage = buildGroundAt(crossSections);
|
||||
for (const section of crossSections) {
|
||||
const design = section.design;
|
||||
/**
|
||||
* BOX암거는 행 **가로 방향을 기준 측점 좌향으로 고정**한다(2026-08-27 사용자:
|
||||
* "박스암거는 축 방향과 동일하게 놓여야 한다"). 행 자리는 노선 위 그대로 두고
|
||||
* 방향만 고정하는 것 — 투영 커브(`_Corridor_Plan.ts` `frameOf`)와 같은 규칙이라
|
||||
* 지우는 영역과 채우는 영역이 같은 방향으로 선다. 세월교·배수관은 노선 프레임.
|
||||
*/
|
||||
const boxAxis = section.box ? section.frame.left_xy : null;
|
||||
const frameOf = (chainageM: number): RouteFrame | null => {
|
||||
const frame = frameAt(chainageM);
|
||||
if (!frame || !boxAxis) return frame;
|
||||
return { ...frame, leftX: boxAxis[0], leftY: boxAxis[1] };
|
||||
};
|
||||
const ownFrame = frameOf(section.chainage_m) ?? {
|
||||
cx: section.center_x,
|
||||
cy: section.center_y,
|
||||
leftX: section.frame.left_xy[0],
|
||||
leftY: section.frame.left_xy[1],
|
||||
designZ: null,
|
||||
};
|
||||
for (const silhouette of structureSilhouettes(section)) {
|
||||
// 노견 확장점 안쪽(수평 구간)은 노견 조각이 가져갔다 — 패치는 그 바깥부터.
|
||||
// 단 그 측에 측구가 있으면 노견을 안 넓혔으므로 스트립도 하지 않는다 — 하면
|
||||
@@ -268,6 +332,14 @@ export function buildStructurePatchRibbons(
|
||||
}
|
||||
}
|
||||
if (source.length < 2) continue;
|
||||
// 이 줄이 소유 측점에서 지반에 닿는가 — 닿는 줄만 행별 지반 트림 대상이다.
|
||||
// 벽 뒷면·구체 상단에서 끝나는 줄을 트림하면 8.1m 절벽이 되살아난다(2026-08-24).
|
||||
const stationGround = groundInterpolator(section.samples);
|
||||
const runOuter = source[source.length - 1];
|
||||
const runReachesGround =
|
||||
stationGround !== null &&
|
||||
Math.abs(runOuter.elevation_m - stationGround(runOuter.offset_m)) <=
|
||||
PATCH_TRIM_TOLERANCE_M;
|
||||
// "auto" = 실루엣 가운데 표고가 지반보다 높으면 성토, 낮으면 절토.
|
||||
let kind: CorridorKind = silhouette.kind === "auto" ? "fill" : silhouette.kind;
|
||||
if (silhouette.kind === "auto") {
|
||||
@@ -276,8 +348,12 @@ export function buildStructurePatchRibbons(
|
||||
if (groundAt && mid.elevation_m < groundAt(mid.offset_m) - 0.05) kind = "cut";
|
||||
}
|
||||
const colCount = PIECE_COLS[kind];
|
||||
const from = section.chainage_m - silhouette.span.beforeM;
|
||||
const to = section.chainage_m + silhouette.span.afterM;
|
||||
const spanFrom = section.chainage_m - silhouette.span.beforeM;
|
||||
const spanTo = section.chainage_m + silhouette.span.afterM;
|
||||
// 날개 사다리꼴 몫만큼 더 뻗는다(2026-08-27 사용자 ②) — 지우는 영역과 맞춘다.
|
||||
const wing = wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame);
|
||||
const from = Math.min(spanFrom, section.chainage_m - wing.beforeM);
|
||||
const to = Math.max(spanTo, section.chainage_m + wing.afterM);
|
||||
if (!(to - from > 1e-6)) continue;
|
||||
const rowChainages: number[] = [];
|
||||
for (let at = from; at < to; at += 1) rowChainages.push(at);
|
||||
@@ -312,13 +388,17 @@ export function buildStructurePatchRibbons(
|
||||
};
|
||||
|
||||
for (const at of rowChainages) {
|
||||
const frame = frameAt(at);
|
||||
const frame = frameOf(at);
|
||||
if (!frame) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
let points = source;
|
||||
// 벽 트림은 **옛 한 줄** 전용이다. 한 줄은 구조물을 가로질러 그어져 벽이 덮은
|
||||
// 벽 트림은 **옛 한 줄** 전용이다. 날개 몫으로 늘린 행도 트림하지 않는다 —
|
||||
// **날개벽 평면 스케치까지 변형 성토면에 포함**하는 게 맞다(2026-08-27 사용자):
|
||||
// 벽 솔리드가 그 위에 불투명하게 앉으므로 덮여도 안 보이고, 트림을 걸면
|
||||
// 날개 사다리꼴이 통째로 비어 구멍이 남는다(실측 덮임 0~36%).
|
||||
// 옛 한 줄 전용 근거: 한 줄은 구조물을 가로질러 그어져 벽이 덮은
|
||||
// 안쪽을 잘라내야 했다. B06 구간 배열은 이미 벽 **사이**에 정의돼 있어 겹치는
|
||||
// 건 아랫단 벽 뒷면 0.15m 남짓뿐이고 그것도 불투명한 벽 솔리드 안에 묻힌다.
|
||||
// 반대로 트림을 걸면 바깥 끝 하나로 자르는 규칙이라 단 사이 구간이 아랫단 벽
|
||||
@@ -343,6 +423,32 @@ export function buildStructurePatchRibbons(
|
||||
points = cut;
|
||||
}
|
||||
}
|
||||
// 행별 지반 절단·연장(2026-08-27 사용자) — 지반에 닿는 줄은 그 행 지반선과
|
||||
// **직접 교차시켜 끝낸다**(빨간 교차선). 소유 측점 단면 한 장을 복사한 것이라
|
||||
// · 지반이 올라오면 교차점까지 **자르고**(교차선 밖은 스커트가 수직 마감),
|
||||
// · 지반이 내려가면 마지막 구배 그대로 **연장**해 원본 성토 끝선까지 간다.
|
||||
// 연장이 없으면 날개 사다리꼴 바깥변까지 2.4~4.5m 모자라 구멍이 남는다
|
||||
// (2026-08-27 실측: BOX 200.9 날개 표본 전량이 횡방향 밖). 규칙·한도는 원래
|
||||
// 성토면과 같다(`_Corridor_Station.ts` trimSlopeToGround).
|
||||
if (runReachesGround) {
|
||||
const ground = groundOfChainage(at);
|
||||
if (ground) {
|
||||
const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0;
|
||||
const placed = points.map((point) => ({
|
||||
offset_m: point.offset_m,
|
||||
elevation_m: point.elevation_m + shift,
|
||||
}));
|
||||
const trimmed = trimSlopeToGround(placed, kind, ground);
|
||||
// null(시작부터 지반 반대편)이면 그대로 둔다 — 행을 죽이면 구멍이 된다.
|
||||
if (trimmed && trimmed.length >= 2) {
|
||||
// flush가 shift를 다시 얹으므로 여기서는 되돌려 저장한다.
|
||||
points = trimmed.map((point) => ({
|
||||
offset_m: point.offset_m,
|
||||
elevation_m: point.elevation_m - shift,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer.push({ chainage: at, frame, points });
|
||||
}
|
||||
flush();
|
||||
|
||||
@@ -24,7 +24,13 @@ import type { ModelBounds } from "./B05_Profile_UI_Markers";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
|
||||
import type { P2, PlanRegion, RegionSegment } from "./B05_Profile_UI_Corridor_Region";
|
||||
import { CorridorStrip, pointInTri2, segmentsIntersect } from "./B05_Profile_UI_Corridor_Region";
|
||||
import {
|
||||
CorridorStrip,
|
||||
PlanLoopRegion,
|
||||
SubtractedRegion,
|
||||
pointInTri2,
|
||||
segmentsIntersect,
|
||||
} from "./B05_Profile_UI_Corridor_Region";
|
||||
|
||||
export { CorridorStrip };
|
||||
|
||||
@@ -363,6 +369,49 @@ function clipPoints(
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 안착 자리에서 **원지반을 되살린다**(2026-08-27 사용자 확정 공식) —
|
||||
*
|
||||
* 되살릴 영역 = 원본 성토사면 스케치(`slope-original`, 파란 커브)
|
||||
* − (구조물 평면 투영 스케치(`structure`) ∪ 변형 성토사면 스케치(`slope-deformed`))
|
||||
*
|
||||
* 원래 성토면이 덮던 자리에서 지금도 무언가(구조물 또는 변형 성토면)가 덮는 자리를
|
||||
* 빼면, 성토가 사라져 원지반이 드러나야 하는 자리만 남는다. **변형 성토사면이 없는
|
||||
* 세트도 있다**(사용자) — 그때는 구조물 스케치만 뺀다. 원본 커브가 없으면 되살릴
|
||||
* 것도 없다(스트립 그대로).
|
||||
*/
|
||||
function withStructureHoles(
|
||||
strip: CorridorStrip,
|
||||
build: CorridorBuildResult,
|
||||
bounds: ModelBounds,
|
||||
): PlanRegion {
|
||||
const loopsOf = (sources: ReadonlyArray<string>): Array<Array<[number, number]>> => {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
for (const curve of build.planCurves ?? []) {
|
||||
if (!sources.includes(curve.source)) continue;
|
||||
for (const loop of curve.loops) {
|
||||
if (loop.length >= 4) loops.push(loop.map(([x, y]) => [x, y] as [number, number]));
|
||||
}
|
||||
}
|
||||
return loops;
|
||||
};
|
||||
const originalLoops = loopsOf(["slope-original"]);
|
||||
if (!originalLoops.length) return strip;
|
||||
const original = new PlanLoopRegion(originalLoops, bounds);
|
||||
if (original.isEmpty) return strip;
|
||||
// 지금도 덮여 있는 자리 — 구조물 ∪ 변형 성토사면(없을 수 있다).
|
||||
const coveredLoops = loopsOf(["structure", "slope-deformed"]);
|
||||
const covered = coveredLoops.length ? new PlanLoopRegion(coveredLoops, bounds) : null;
|
||||
const expose: PlanRegion =
|
||||
covered && !covered.isEmpty ? new SubtractedRegion(original, [covered]) : original;
|
||||
// 검증용 요약(화면 밖 수치 확인) — 고리 수. `__corridorClip`과 같은 용도.
|
||||
(window as unknown as { __corridorHoles?: unknown }).__corridorHoles = {
|
||||
original: originalLoops.length,
|
||||
covered: coveredLoops.length,
|
||||
};
|
||||
return new SubtractedRegion(strip, [expose]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코리도 스트립으로 도려낸 지형을 만든다 — 원본은 불변.
|
||||
*
|
||||
@@ -378,7 +427,9 @@ export function clipTerrain(
|
||||
build: CorridorBuildResult,
|
||||
bounds: ModelBounds,
|
||||
): THREE.Object3D {
|
||||
const regions: PlanRegion[] = [new CorridorStrip(build, bounds)];
|
||||
const regions: PlanRegion[] = [
|
||||
withStructureHoles(new CorridorStrip(build, bounds), build, bounds),
|
||||
];
|
||||
const root = new THREE.Group();
|
||||
root.name = "terrain-clipped";
|
||||
// geometry·material 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다.
|
||||
|
||||
@@ -433,19 +433,27 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou
|
||||
}
|
||||
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 사용자). 패치 스커트는 성토면의 연장이라 **빗금 있는 메시
|
||||
// 서피스**로 그린다(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 = `corridor-cut-wall:${wall.side}:${index}`;
|
||||
mesh.name = wall.patchSkirt
|
||||
? `corridor-patch-skirt:${wall.side}:${index}`
|
||||
: `corridor-cut-wall:${wall.side}:${index}`;
|
||||
group.add(mesh);
|
||||
});
|
||||
build.caps.forEach((cap, index) => {
|
||||
|
||||
@@ -90,7 +90,7 @@ interface PlanFrame {
|
||||
}
|
||||
|
||||
/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */
|
||||
function buildGroundAt(
|
||||
export function buildGroundAt(
|
||||
crossSections: ReadonlyArray<CrossSection>,
|
||||
): (chainageM: number) => ((offset: number) => number) | null {
|
||||
const rows = crossSections
|
||||
|
||||
@@ -122,6 +122,14 @@ export class CellRegion implements PlanRegion {
|
||||
}
|
||||
|
||||
protected addSegment(p: P2, q: P2): void {
|
||||
// 선분만 있는 영역(고리 영역)도 경계 상자를 갖도록 여기서도 넓힌다 — 셀 영역은
|
||||
// 선분이 셀 위에 놓이므로 값이 달라지지 않는다.
|
||||
[p, q].forEach((point) => {
|
||||
if (point.x < this.minX) this.minX = point.x;
|
||||
if (point.x > this.maxX) this.maxX = point.x;
|
||||
if (point.z < this.minZ) this.minZ = point.z;
|
||||
if (point.z > this.maxZ) this.maxZ = point.z;
|
||||
});
|
||||
this.segments.push({
|
||||
p,
|
||||
q,
|
||||
@@ -134,10 +142,16 @@ export class CellRegion implements PlanRegion {
|
||||
|
||||
/** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 셀 한 변). */
|
||||
protected buildIndex(): void {
|
||||
if (!this.cells.length) return;
|
||||
// 셀이 없으면 선분 크기로 격자를 잡는다 — 고리 영역(`PlanLoopRegion`)은 셀 없이
|
||||
// 경계 선분만 들고 있다(2026-08-27).
|
||||
const sources: Array<{ minX: number; maxX: number; minZ: number; maxZ: number }> = this.cells
|
||||
.length
|
||||
? this.cells
|
||||
: this.segments;
|
||||
if (!sources.length) return;
|
||||
const meanCell =
|
||||
this.cells.reduce((sum, cell) => sum + (cell.maxX - cell.minX) + (cell.maxZ - cell.minZ), 0) /
|
||||
this.cells.length;
|
||||
sources.reduce((sum, item) => sum + (item.maxX - item.minX) + (item.maxZ - item.minZ), 0) /
|
||||
sources.length;
|
||||
this.grid = Math.max(2, meanCell);
|
||||
this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1);
|
||||
this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1);
|
||||
@@ -247,3 +261,144 @@ export class CorridorStrip extends CellRegion {
|
||||
this.buildIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫힌 고리들이 덮는 평면 영역(2026-08-27 사용자 ③) — 구조물 풋프린트·날개 사다리꼴처럼
|
||||
* 격자에 안 실리는 임의 다각형을 담는다. 오목해도 옳도록 내부 판정은 **광선 투사**다
|
||||
* (부채꼴 삼각분할은 오목 고리에서 틀린다). 경계 선분은 상속받은 격자로 색인해
|
||||
* 절단기가 삼각형마다 후보만 보게 한다.
|
||||
*/
|
||||
/** 고리 마디 하나로 볼 최소 길이(m)·꺾임 여유(m) — 절단선 수를 줄이는 단순화 기준. */
|
||||
const LOOP_MIN_SEGMENT_M = 0.25;
|
||||
const LOOP_COLLINEAR_M = 0.02;
|
||||
|
||||
/**
|
||||
* 고리를 **절단선 수 기준으로** 단순화한다(2026-08-27). 경계 선분 하나가 무한직선 하나로
|
||||
* 삼각형을 자르므로, 구조물 풋프린트처럼 점이 촘촘한 고리를 그대로 쓰면 한 삼각형이
|
||||
* 수십 번 쪼개져 조각이 폭증한다(실측: 구멍 134㎡ 자리에 7,948조각). 짧은 마디와 거의
|
||||
* 일직선인 점을 걷어내면 형상은 그대로 두고 절단선만 준다.
|
||||
*/
|
||||
function simplifyLoop(points: ReadonlyArray<P2>): P2[] {
|
||||
const kept: P2[] = [];
|
||||
for (const point of points) {
|
||||
const last = kept[kept.length - 1];
|
||||
if (last && Math.hypot(point.x - last.x, point.z - last.z) < LOOP_MIN_SEGMENT_M) continue;
|
||||
kept.push(point);
|
||||
}
|
||||
// 시작·끝이 겹치면 하나로 — 광선 투사는 자동으로 닫는다.
|
||||
if (kept.length > 1) {
|
||||
const first = kept[0];
|
||||
const last = kept[kept.length - 1];
|
||||
if (Math.hypot(first.x - last.x, first.z - last.z) < LOOP_MIN_SEGMENT_M) kept.pop();
|
||||
}
|
||||
if (kept.length < 4) return kept;
|
||||
const thinned: P2[] = [];
|
||||
for (let i = 0; i < kept.length; i += 1) {
|
||||
const previous = thinned[thinned.length - 1] ?? kept[kept.length - 1];
|
||||
const point = kept[i];
|
||||
const next = kept[(i + 1) % kept.length];
|
||||
// 앞뒤를 잇는 직선에서 이 점이 거의 안 벗어나면 뺀다(꺾임이 없다).
|
||||
const dx = next.x - previous.x;
|
||||
const dz = next.z - previous.z;
|
||||
const length = Math.hypot(dx, dz);
|
||||
const offset =
|
||||
length < 1e-9
|
||||
? Math.hypot(point.x - previous.x, point.z - previous.z)
|
||||
: Math.abs(dx * (point.z - previous.z) - dz * (point.x - previous.x)) / length;
|
||||
if (offset > LOOP_COLLINEAR_M) thinned.push(point);
|
||||
}
|
||||
return thinned.length >= 3 ? thinned : kept;
|
||||
}
|
||||
|
||||
export class PlanLoopRegion extends CellRegion {
|
||||
private loops: Array<{ points: P2[]; minX: number; maxX: number; minZ: number; maxZ: number }> =
|
||||
[];
|
||||
|
||||
constructor(loops: ReadonlyArray<ReadonlyArray<readonly [number, number]>>, bounds: ModelBounds) {
|
||||
super();
|
||||
const toScene = scenePlanMapper(bounds);
|
||||
for (const loop of loops) {
|
||||
if (loop.length < 3) continue;
|
||||
const points = simplifyLoop(loop.map(([mx, my]) => toScene(mx, my)));
|
||||
if (points.length < 3) continue;
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
points.forEach((point) => {
|
||||
if (point.x < minX) minX = point.x;
|
||||
if (point.x > maxX) maxX = point.x;
|
||||
if (point.z < minZ) minZ = point.z;
|
||||
if (point.z > maxZ) maxZ = point.z;
|
||||
});
|
||||
this.loops.push({ points, minX, maxX, minZ, maxZ });
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
this.addSegment(points[j], points[i]);
|
||||
}
|
||||
}
|
||||
this.buildIndex();
|
||||
}
|
||||
|
||||
override get isEmpty(): boolean {
|
||||
return this.loops.length === 0;
|
||||
}
|
||||
|
||||
override contains(p: P2): boolean {
|
||||
if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false;
|
||||
for (const loop of this.loops) {
|
||||
if (p.x < loop.minX || p.x > loop.maxX || p.z < loop.minZ || p.z > loop.maxZ) continue;
|
||||
const points = loop.points;
|
||||
let hit = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
const a = points[i];
|
||||
const b = points[j];
|
||||
if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) {
|
||||
hit = !hit;
|
||||
}
|
||||
}
|
||||
if (hit) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **차집합 영역**(2026-08-27 사용자 ③) — 바탕에서 구멍을 뺀 자리만 "안"으로 본다.
|
||||
* 구조물이 안착한 뒤 그 평면 영역에서 원지반이 다시 드러나야 하므로, 지형을 도려낼 때
|
||||
* 코리도 스트립에서 구조물·날개 영역을 뺀다. 경계 선분은 **양쪽 합집합**이라 구멍
|
||||
* 가장자리에서도 지형이 깨끗하게 재절단된다.
|
||||
*/
|
||||
export class SubtractedRegion implements PlanRegion {
|
||||
constructor(
|
||||
private readonly base: PlanRegion,
|
||||
private readonly holes: ReadonlyArray<PlanRegion>,
|
||||
) {}
|
||||
|
||||
get minX(): number {
|
||||
return this.base.minX;
|
||||
}
|
||||
|
||||
get maxX(): number {
|
||||
return this.base.maxX;
|
||||
}
|
||||
|
||||
get minZ(): number {
|
||||
return this.base.minZ;
|
||||
}
|
||||
|
||||
get maxZ(): number {
|
||||
return this.base.maxZ;
|
||||
}
|
||||
|
||||
contains(p: P2, sceneY?: number): boolean {
|
||||
if (!this.base.contains(p, sceneY)) return false;
|
||||
// 구멍은 표고를 따지지 않는다 — 그 평면 자리는 원지반이 드러나는 게 규칙이다.
|
||||
return !this.holes.some((hole) => hole.contains(p));
|
||||
}
|
||||
|
||||
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number): RegionSegment[] {
|
||||
const result = this.base.segmentsNear(minX, maxX, minZ, maxZ);
|
||||
for (const hole of this.holes) result.push(...hole.segmentsNear(minX, maxX, minZ, maxZ));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Skirt.ts
|
||||
* **변형 성토면(패치 리본) 바깥 끝 스커트** — 2026-08-27 사용자.
|
||||
*
|
||||
* 왜: 기본 성토면은 행마다 지반선에 트림되고(`_Corridor_Build.ts` `trimSlopeToGround`)
|
||||
* 최외곽 정점을 지형 메시에 스냅해(`_UI_Viewer.ts` `snapCorridorEdges`) 지반과 맞물린다.
|
||||
* 패치 리본은 구조물 측점 단면 한 장을 종단 shift만 얹어 복사한 것이라 지반 트림이
|
||||
* 없고, 스냅 대상에서도 빠져 있다(스냅하면 벽 뒷면에서 끝나야 할 끝이 원지반까지
|
||||
* 끌려 내려가 4.1m 세로 지느러미가 생겼다 — 2026-08-27 실측). 그래서 구간을 따라
|
||||
* 지반이 오르내리면 패치 바깥 끝이 공중에 뜬다.
|
||||
*
|
||||
* 방식: 정점은 **하나도 건드리지 않는다**. 바깥 끝 모서리에서 원지반까지 판만 세운다
|
||||
* (`_Corridor_Cut.ts`의 절취 측벽과 같은 수법). 지느러미를 막는 두 조건:
|
||||
* · 마디 양 끝이 **지반보다 위**일 때만 세운다 — 잠긴 자리는 지형이 덮는다.
|
||||
* · 낙차가 `PATCH_SKIRT_MAX_M`을 넘는 마디는 건너뛴다 — 벽 뒷면·구체 상단에서
|
||||
* 끝나는 줄이 통째로 원지반까지 떨어지는 걸 막는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CorridorCutWall, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
/** 밑단을 원지반 **아래로 더 내리는 여유**(m) — 지반 보간 오차만큼 밑이 뜨는 걸 막는다.
|
||||
* 절취 측벽(`_Corridor_Cut.ts` `SKIRT_MARGIN_M`)과 같은 값·같은 이유다. */
|
||||
const SKIRT_MARGIN_M = 1;
|
||||
|
||||
/** 세울 수 있는 최대 낙차(m) — 이걸 넘으면 지반과의 틈이 아니라 **끝나는 게 정상인
|
||||
* 자리**(벽 뒷면·구체 상단)로 본다. 넘는 마디는 건너뛴다(2026-08-27 사용자). */
|
||||
const PATCH_SKIRT_MAX_M = 3;
|
||||
|
||||
/** 판을 세울 만한 최소 낙차(m) — 이 아래는 지형이 덮어 보이지 않는다. */
|
||||
const SKIRT_MIN_DROP_M = 0.02;
|
||||
|
||||
/** 빗금 한 타일이 덮는 실거리(m) — 성토 리본의 종방향 규칙(2m마다 1타일)과 같다. */
|
||||
const HATCH_TILE_M = 2;
|
||||
|
||||
/**
|
||||
* **지반에서 끝나는 줄**로 볼 허용 낙차(m) — 소유 측점(구간 한가운데) 행에서 잰다.
|
||||
*
|
||||
* 왜 필요한가(2026-08-27 사용자 지적): 다단 성토는 `성토 > 구조물 > 성토`라 패치가
|
||||
* 여러 줄이고, 안쪽 줄들은 **벽 뒷면·구체 상단에서 끝나는 게 정상**이다. 그 줄에도
|
||||
* 판을 세우면 BOX암거 앞면처럼 없던 서피스가 생긴다(실측: 구간 201에서 바깥 끝이
|
||||
* 지반 위 1.96~2.2m). B06 실루엣은 소유 측점에서 지반에 트림돼 있으므로, 그 행의
|
||||
* 낙차가 이 값 안이면 "지반까지 내려오는 줄", 넘으면 "구조물에서 끝나는 줄"이다.
|
||||
*/
|
||||
const PATCH_TOE_TOLERANCE_M = 0.5;
|
||||
|
||||
/** 검증용 요약 — 화면 밖에서 수치로 확인한다(`window.__corridorSkirt`). */
|
||||
export interface PatchSkirtStats {
|
||||
/** 스커트가 붙은 패치 리본 수. */
|
||||
ribbons: number;
|
||||
/** 세운 삼각형 수. */
|
||||
triangles: number;
|
||||
/** 실제로 세운 마디의 최대 낙차(m). */
|
||||
maxDropM: number;
|
||||
/** 낙차 상한을 넘겨 건너뛴 마디 수. */
|
||||
skippedTall: number;
|
||||
/** 지반 아래(또는 붙어 있어) 건너뛴 마디 수. */
|
||||
skippedSubmerged: number;
|
||||
/** 지반 높이를 못 읽어 건너뛴 마디 수. */
|
||||
skippedNoGround: number;
|
||||
/** 리본별 계측 — 어느 열이 실제 바깥인지, 낙차가 어디서 나는지 확인용. */
|
||||
detail: Array<{
|
||||
side: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
from: number;
|
||||
to: number;
|
||||
/** 첫 행 기준 양 끝 열의 [설계z, 지반z, 낙차]. */
|
||||
col0: [number, number, number];
|
||||
colLast: [number, number, number];
|
||||
/** 소유 측점 행 바깥 끝의 낙차(m)와 그 판정 — 지반에서 끝나는 줄만 스커트 대상. */
|
||||
gateDrop: number;
|
||||
reachesGround: boolean;
|
||||
triangles: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 패치 리본 **바깥 끝 모서리**에 스커트를 세운다.
|
||||
*
|
||||
* `groundAt`은 모델 좌표 (x, y) 자리의 **원지반 모델 z**를 돌려준다(지형 밖이면 null).
|
||||
* 보이는 틈은 렌더된 지형 메시와의 틈이므로 호출부가 지형 메시 색인을 넘긴다 —
|
||||
* 횡단 샘플러 보간값을 쓰면 지반이 두 벌이 되어 또 미세 틈이 난다.
|
||||
*/
|
||||
export function buildPatchSkirts(
|
||||
ribbons: ReadonlyArray<CorridorRibbon>,
|
||||
groundAt: (x: number, y: number) => number | null,
|
||||
): { walls: CorridorCutWall[]; stats: PatchSkirtStats } {
|
||||
const walls: CorridorCutWall[] = [];
|
||||
const stats: PatchSkirtStats = {
|
||||
ribbons: 0,
|
||||
triangles: 0,
|
||||
maxDropM: 0,
|
||||
skippedTall: 0,
|
||||
skippedSubmerged: 0,
|
||||
skippedNoGround: 0,
|
||||
detail: [],
|
||||
};
|
||||
for (const ribbon of ribbons) {
|
||||
if (!ribbon.patch || ribbon.kind !== "fill") continue;
|
||||
if (ribbon.side !== "left" && ribbon.side !== "right") continue;
|
||||
const cols = ribbon.colCount;
|
||||
const rows = ribbon.chainages.length;
|
||||
if (cols < 2 || rows < 2) continue;
|
||||
// 패치 리본은 단면을 **수집 순서(도로측 → 바깥)** 그대로 담는다 — Build 리본과
|
||||
// 달리 우측을 뒤집지 않으므로 좌·우 모두 마지막 열이 바깥 끝이다(`_Carve.ts`).
|
||||
const outerCol = cols - 1;
|
||||
const tris: number[] = [];
|
||||
// 계측용 — 첫 행 양 끝 열의 설계z·지반z·낙차(어느 열이 실제 바깥인지 확인).
|
||||
const probe = (col: number): [number, number, number] => {
|
||||
const i = col * 3;
|
||||
const z = ribbon.positions[i + 2];
|
||||
const g = groundAt(ribbon.positions[i], ribbon.positions[i + 1]);
|
||||
return [z, g ?? NaN, g === null ? NaN : z - g];
|
||||
};
|
||||
const col0 = probe(0);
|
||||
const colLast = probe(cols - 1);
|
||||
// **지반에서 끝나는 줄인가** — 소유 측점(구간 한가운데) 행에서 잰다. 벽 뒷면·구체
|
||||
// 상단에서 끝나는 줄이면 여기서 빠진다(2026-08-27 사용자: BOX암거 앞면에 없던
|
||||
// 서피스가 생겼다).
|
||||
const middle = Math.floor(rows / 2);
|
||||
const gateIndex = (middle * cols + outerCol) * 3;
|
||||
const gateGround = groundAt(ribbon.positions[gateIndex], ribbon.positions[gateIndex + 1]);
|
||||
const gateDrop = gateGround === null ? NaN : ribbon.positions[gateIndex + 2] - gateGround;
|
||||
const reachesGround = gateGround !== null && gateDrop <= PATCH_TOE_TOLERANCE_M;
|
||||
// UV — u는 모서리를 따라 걸은 실거리, v는 표고. 둘 다 2m마다 한 타일이라 빗금이
|
||||
// 성토 리본과 같은 실척으로 이어진다(2026-08-27 사용자: 스커트는 메시 서피스).
|
||||
const uvs: number[] = [];
|
||||
let walked = 0;
|
||||
let previous: { x: number; y: number; top: number; bottom: number } | null = null;
|
||||
for (let row = 0; reachesGround && row < rows; row += 1) {
|
||||
const i = (row * cols + outerCol) * 3;
|
||||
const x = ribbon.positions[i];
|
||||
const y = ribbon.positions[i + 1];
|
||||
const top = ribbon.positions[i + 2];
|
||||
const ground = groundAt(x, y);
|
||||
const current =
|
||||
ground === null ? null : { x, y, top, bottom: Math.min(top, ground) - SKIRT_MARGIN_M };
|
||||
const dropOf = (point: NonNullable<typeof current>): number =>
|
||||
point.top - (point.bottom + SKIRT_MARGIN_M);
|
||||
if (previous && current) {
|
||||
const dropA = dropOf(previous);
|
||||
const dropB = dropOf(current);
|
||||
if (dropA < SKIRT_MIN_DROP_M && dropB < SKIRT_MIN_DROP_M) {
|
||||
stats.skippedSubmerged += 1;
|
||||
} else if (Math.max(dropA, dropB) > PATCH_SKIRT_MAX_M) {
|
||||
stats.skippedTall += 1;
|
||||
} else {
|
||||
const uPrev = walked / HATCH_TILE_M;
|
||||
const uCurrent =
|
||||
(walked + Math.hypot(current.x - previous.x, current.y - previous.y)) / HATCH_TILE_M;
|
||||
tris.push(
|
||||
previous.x,
|
||||
previous.y,
|
||||
previous.top,
|
||||
current.x,
|
||||
current.y,
|
||||
current.top,
|
||||
current.x,
|
||||
current.y,
|
||||
current.bottom,
|
||||
);
|
||||
uvs.push(
|
||||
uPrev,
|
||||
previous.top / HATCH_TILE_M,
|
||||
uCurrent,
|
||||
current.top / HATCH_TILE_M,
|
||||
uCurrent,
|
||||
current.bottom / HATCH_TILE_M,
|
||||
);
|
||||
tris.push(
|
||||
previous.x,
|
||||
previous.y,
|
||||
previous.top,
|
||||
current.x,
|
||||
current.y,
|
||||
current.bottom,
|
||||
previous.x,
|
||||
previous.y,
|
||||
previous.bottom,
|
||||
);
|
||||
uvs.push(
|
||||
uPrev,
|
||||
previous.top / HATCH_TILE_M,
|
||||
uCurrent,
|
||||
current.bottom / HATCH_TILE_M,
|
||||
uPrev,
|
||||
previous.bottom / HATCH_TILE_M,
|
||||
);
|
||||
stats.triangles += 2;
|
||||
stats.maxDropM = Math.max(stats.maxDropM, dropA, dropB);
|
||||
}
|
||||
} else if (previous || current) {
|
||||
stats.skippedNoGround += 1;
|
||||
}
|
||||
if (previous && current) {
|
||||
walked += Math.hypot(current.x - previous.x, current.y - previous.y);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
stats.detail.push({
|
||||
side: ribbon.side,
|
||||
rows,
|
||||
cols,
|
||||
from: ribbon.chainages[0],
|
||||
to: ribbon.chainages[rows - 1],
|
||||
col0,
|
||||
colLast,
|
||||
gateDrop,
|
||||
reachesGround,
|
||||
triangles: tris.length / 9,
|
||||
});
|
||||
if (tris.length) {
|
||||
walls.push({
|
||||
side: ribbon.side,
|
||||
positions: new Float64Array(tris),
|
||||
patchSkirt: true,
|
||||
uvs: new Float32Array(uvs),
|
||||
});
|
||||
stats.ribbons += 1;
|
||||
}
|
||||
}
|
||||
return { walls, stats };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
|
||||
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
|
||||
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
|
||||
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
|
||||
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
|
||||
|
||||
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
|
||||
@@ -561,6 +562,27 @@ export function createRouteViewer(): RouteViewer {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을
|
||||
* 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은
|
||||
* `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다.
|
||||
*/
|
||||
function attachPatchSkirts(build: CorridorBuildResult): void {
|
||||
if (!bounds) return;
|
||||
const index = ensureHeightIndex();
|
||||
if (!index) return;
|
||||
const ox = (bounds.x[0] + bounds.x[1]) / 2;
|
||||
const oy = (bounds.y[0] + bounds.y[1]) / 2;
|
||||
const oz = (bounds.z[0] + bounds.z[1]) / 2;
|
||||
const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => {
|
||||
const height = index.heightAt(x - ox, -(y - oy));
|
||||
return height === null ? null : height + oz;
|
||||
});
|
||||
const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt);
|
||||
build.cutWalls = [...kept, ...walls];
|
||||
(window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats;
|
||||
}
|
||||
|
||||
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
|
||||
function scheduleTerrainClip(): void {
|
||||
disposeClippedTerrain();
|
||||
@@ -628,6 +650,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
: null;
|
||||
if (build && bounds) {
|
||||
snapCorridorEdges(build);
|
||||
attachPatchSkirts(build);
|
||||
corridorGroup = createCorridorGroup(build, bounds);
|
||||
scene.add(corridorGroup);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user