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:
2026-08-27 18:42:15 +09:00
co-authored by Claude Opus 5
parent 4a5a98fe44
commit a864acd17d
9 changed files with 599 additions and 14 deletions
+11 -1
View File
@@ -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 {
+10 -1
View File
@@ -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],
+110 -4
View File
@@ -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();
+53 -2
View File
@@ -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 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다.
+10 -2
View File
@@ -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) => {
+1 -1
View File
@@ -90,7 +90,7 @@ interface PlanFrame {
}
/** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */
function buildGroundAt(
export function buildGroundAt(
crossSections: ReadonlyArray<CrossSection>,
): (chainageM: number) => ((offset: number) => number) | null {
const rows = crossSections
+158 -3
View File
@@ -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 };
}
+23
View File
@@ -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);
}