diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 551340a2..2758ba4f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 4a38d412..ae3600b5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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], diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 416f8566..69f4bfd8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -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, + 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, frameAt: (chainageM: number) => RouteFrame | null, profileZ: ((chainageM: number) => number | null) | null, + planCurves: ReadonlyArray = [], ): 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(); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts index e78bd400..242e752a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -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): Array> => { + const loops: Array> = []; + 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 소유권 표시 — 분할본이 들고 있는 것은 여기서 해제하면 안 된다. diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 12cfb2c8..ca4757d2 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -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) => { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index 2909a034..bcdda845 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -90,7 +90,7 @@ interface PlanFrame { } /** 누가거리 → 그 행의 지반선. 인접 두 측점 지반을 섞는다(코리도 빌드와 같은 방식). */ -function buildGroundAt( +export function buildGroundAt( crossSections: ReadonlyArray, ): (chainageM: number) => ((offset: number) => number) | null { const rows = crossSections diff --git a/B05_Profile/B05_Profile_UI_Corridor_Region.ts b/B05_Profile/B05_Profile_UI_Corridor_Region.ts index 3ed925ba..e7562c5f 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Region.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Region.ts @@ -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[] { + 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>, 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, + ) {} + + 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; + } +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Skirt.ts b/B05_Profile/B05_Profile_UI_Corridor_Skirt.ts new file mode 100644 index 00000000..caed3e43 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Skirt.ts @@ -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, + 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): 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 }; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 1e48219d..d113174a 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -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); }