fix(B05): 원지반 재노출 영역을 실제로 도려낸 자리 윤곽(cut-merged)으로 바꾼다

구조물 자리에 원지반이 드러나지 않거나, 드러나도 날개벽 자리에 성토면도 지형도
없는 검은 슬리버가 남던 문제.

원인은 지우는 쪽과 되살리는 쪽이 **서로 다른 자료로 영역을 만든 것**이다.
- 지우는 쪽: 성토 리본 셀 마스크(slope-projected ∪ wing-box, 0.2m 격자 + 현 절단)
- 되살리는 쪽: 원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)를 **다시 조립**
구조물 고리에는 날개벽이 패널 두께 슬리버로 섞여 있어 그 자리만 covered로 빠졌고,
BOX암거에서는 변형 성토선이 원본과 collinear가 되어 영역이 통째로 공집합이었다.

이제 지우는 쪽이 남긴 자국을 그대로 쓴다 — cut-merged 는 _Corridor_Cut.ts 의
traceMaskLoops 가 셀 마스크에서 되짚은 윤곽이다. 그 안의 지형은 도려내지 않으므로
**원본 지형 메시가 그 자리에 그대로 남는다**. 새 서피스를 만들지 않는다
(2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까", "회색 서피스는
있는데 원지반과 같은 메시가 아니잖아. 너가 그냥 만든 거잖아").

- Clip: withStructureHoles 를 cut-merged 한 줄로 대체. slope-original/wing-box/
  structure/slope-deformed 조합과 axisLocked 분기 삭제(순 -20줄). __corridorHoles
  는 고리 수 대신 exposeLoops/exposeCells/exposeRatio 를 담는다.
- BUILD_VERSION 82 -> 83.

실측(공용 브라우저):
- cut-merged 고리 8개 = 구조물 4세트 x 좌우.
- exposeCells 23/6400(0.36%) -> 164/6400(2.56%). 7배.
- 지형 유지 삼각형 474,854 -> 476,885 (+2,031).
- tsc --noEmit 0.

폐기한 시도(같은 브랜치 2d897164 이후 작업트리에서만 존재했다):
성토 격자에 원지반 표고를 재샘플링해 역마스크로 까는 방식. 경계는 정의상
일치했으나 코리도 격자로 다시 만든 면이라 원지반 메시와 형상이 달랐다.

남은 것: 유출구 일부 성토부 터짐(사용자 확인) — 다음 작업.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:32:01 +09:00
co-authored by Claude Opus 5
parent 2d897164cc
commit 5118482767
2 changed files with 35 additions and 55 deletions
+7 -1
View File
@@ -183,7 +183,13 @@ function fnv1a(text: string): string {
// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가
// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은
// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다.
const BUILD_VERSION = 82;
// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로
// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을
// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
const BUILD_VERSION = 83;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+28 -54
View File
@@ -22,7 +22,6 @@
import * as THREE from "three";
import type { ModelBounds } from "./B05_Profile_UI_Markers";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan";
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
import type { P2, PlanRegion, RegionSegment } from "./B05_Profile_UI_Corridor_Region";
import {
@@ -371,76 +370,51 @@ function clipPoints(
}
/**
* 구조물 안착 자리에서 **원지반을 되살린다**(2026-08-27 사용자 확정 공식) —
* 구조물 안착 자리에서 **원지반을 되살린다** — 그 자리 지형을 도려내지 않고 **원본
* 지형 메시 그대로** 남긴다(2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까",
* "회색 서피스는 있는데 원지반과 같은 메시가 아니잖아. 너가 그냥 만든 거잖아").
*
* 되살릴 영역 = 원본 성토사면 스케치(`slope-original`, 파란 커브)
* − (구조물 평면 투영 스케치(`structure`) 변형 성토사면 스케치(`slope-deformed`))
* 되살릴 영역 = **`cut-merged`** — 성토면에서 **실제로 도려낸 셀**을 되짚어 낸 윤곽이다
* (`_Corridor_Cut.ts` `traceMaskLoops`). 지우는 쪽이 남긴 자국을 그대로 쓰므로, 구조물
* 풋프린트로 영역을 다시 조립할 필요가 없다.
*
* 원래 성토면이 덮던 자리에서 지금도 무언가(구조물 또는 변형 성토면)가 덮는 자리를
* 빼면, 성토가 사라져 원지반이 드러나야 하는 자리만 남는다. **변형 성토사면이 없는
* 세트도 있다**(사용자) — 그때는 구조물 스케치만 뺀다. 원본 커브가 없으면 되살릴
* 것도 없다(스트립 그대로).
* 종전에는 `원본 성토 스케치 (구조물 변형 성토 스케치)`로 영역을 **다시 만들었다**.
* 지우는 쪽(성토 셀 마스크)과 되살리는 쪽(구조물 고리)이 서로 다른 커브를 써 경계가
* 어긋났고, 날개벽이 패널 두께 슬리버로 남아 그 자리만 성토면도 지형도 없는 검은 줄이
* 됐다. BOX암거에서는 변형 성토선이 원본과 collinear가 되어 영역이 통째로 공집합이 됐다.
*
* `cut-merged`가 없으면(절취를 안 했으면) 되살릴 것도 없다 — 스트립 그대로.
*/
function withStructureHoles(
strip: CorridorStrip,
build: CorridorBuildResult,
bounds: ModelBounds,
): PlanRegion {
const loopsOf = (
sources: ReadonlyArray<string>,
only?: (curve: PlanCurve) => boolean,
): Array<Array<[number, number]>> => {
const loops: Array<Array<[number, number]>> = [];
for (const curve of build.planCurves ?? []) {
if (!sources.includes(curve.source)) continue;
if (only && !only(curve)) continue;
for (const loop of curve.loops) {
if (loop.length >= 4) loops.push(loop.map(([x, y]) => [x, y] as [number, number]));
}
const loops: Array<Array<[number, number]>> = [];
for (const curve of build.planCurves ?? []) {
if (curve.source !== "cut-merged") continue;
for (const loop of curve.loops) {
if (loop.length >= 4) loops.push(loop.map(([x, y]) => [x, y] as [number, number]));
}
return loops;
};
/**
* BOX암거 **날개 사다리꼴**(`wing-box`)도 되살릴 후보에 넣는다(2026-08-27 사용자:
* "박스암거의 날개부의 평면 스케치 영역까지 원지반이 같이 노출되어야 함").
*
* 그 자리는 절취 소스(`_Corridor_Cut.ts` `CUT_SOURCES`)에 들어 있어 성토면이
* 파이는데, 되살릴 후보 목록(`slope-original`)에는 없어 **성토면도 원지반도 없는
* 구멍**으로 남았다. 지우는 이름 목록과 되살리는 이름 목록이 어긋난 것이다.
*
* 세월교는 넣지 않는다 — 그 측 날개 자리는 되메움 패치가 덮는다(`_Corridor_Carve.ts`
* `wingRangeAt`, `isFord` 전용). 지형까지 올리면 패치면과 같은 높이에서 겹쳐
* z-fight가 난다. `axisLocked`가 곧 BOX 표식이다(`_Corridor_Plan.ts` `boxAxis`).
*/
const originalLoops = [
...loopsOf(["slope-original"]),
...loopsOf(["wing-box"], (curve) => curve.axisLocked === true),
];
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`과 같은 용도. **고리 수만으로는
// 되살릴 영역이 실제로 남았는지 알 수 없다**: BOX암거에서 `covered`가 `original`을
// 통째로 삼켜 공집합이 됐는데도 이 훅은 정상값을 찍었다(2026-08-27). 되살릴 영역을
// 격자로 찍어 실적을 함께 담는다 — 코리도 빌드당 한 번이라 비용은 무시할 만하다.
}
if (!loops.length) return strip;
const expose = new PlanLoopRegion(loops, bounds);
if (expose.isEmpty) return strip;
// 검증용 요약(화면 밖 수치 확인) — `__corridorClip`과 같은 용도. 고리 수만으로는
// 되살릴 영역이 실제로 남았는지 알 수 없어(BOX에서 공집합인데도 정상값이 찍혔다)
// 영역을 격자로 찍은 실적을 함께 담는다. 코리도 빌드당 한 번이라 비용은 무시할 만하다.
const SAMPLES = 80;
let inside = 0;
for (let i = 0; i < SAMPLES; i += 1) {
const x = original.minX + ((original.maxX - original.minX) * (i + 0.5)) / SAMPLES;
const x = expose.minX + ((expose.maxX - expose.minX) * (i + 0.5)) / SAMPLES;
for (let j = 0; j < SAMPLES; j += 1) {
const z = original.minZ + ((original.maxZ - original.minZ) * (j + 0.5)) / SAMPLES;
const z = expose.minZ + ((expose.maxZ - expose.minZ) * (j + 0.5)) / SAMPLES;
if (expose.contains({ x, z })) inside += 1;
}
}
(window as unknown as { __corridorHoles?: unknown }).__corridorHoles = {
original: originalLoops.length,
covered: coveredLoops.length,
/** 되살릴 영역에 든 표본 점 수 / 전체 표본(원본 bbox 격자 80×80). 0이면 공집합. */
/** 되살릴 영역 고리 수 = 실제로 도려낸 자리 윤곽(`cut-merged`) 개수. */
exposeLoops: loops.length,
exposeCells: inside,
exposeRatio: Number((inside / (SAMPLES * SAMPLES)).toFixed(4)),
};