fix(B05): 잘린 자리 윤곽을 실좌표에서 다듬어 성토부 터짐을 막는다

유출구 쪽 성토부에 성토면도 지형도 없는 검은 쐐기가 남던 문제(2026-08-27 사용자:
"유출구에 일부 성토부에 터짐 있음").

원인 두 가지.
- dropCollinear 가 **격자 인덱스 공간**에서 공선 판정을 했다. 코리도 격자는 노선을
  따라 휘어 있어 인덱스로 직선인 구간이 실좌표에서는 곡선이다. 그걸 지우면 폴리곤이
  실제 마스크 안쪽으로 코너를 잘라먹고, 그 밖 셀은 되살릴 영역에서 빠져 구멍이 된다.
  빠진 셀이 전부 편거리가 큰 바깥 끝 열(40~46)이었던 것도 이것으로 설명된다.
- traceMaskLoops 가 갈림목에서 아무 변이나 집어(pop) walk 가 남의 고리로 새 나가고,
  막다른 길에서 끊긴 열린 사슬을 고리로 담았다. 표시용일 땐 안 보였지만 이 커브가
  되살릴 영역의 근거가 된 뒤로는 판정을 무너뜨린다.

- Cut: dropCollinear 를 실좌표(모델 XY) 기준 수직거리 1mm 판정으로 바꾸고, 고리를
  **실좌표로 옮긴 뒤** 다듬는다. traceMaskLoops 는 갈림목을 오른손 규칙(마스크를
  왼쪽에 두고 담으므로 우회전 우선)으로 가르고 **닫힌 고리만** 담는다.
- BUILD_VERSION 83 -> 84.

실측(공용 브라우저) — 도려낸 셀 중 되살릴 영역이 안 덮는 셀:
- 우측 1,447/7,941(18%) -> 109/7,941(1.4%)
- 좌측 1,292/7,021(18%) -> 14/7,021(0.2%)
- 지형 유지 삼각형 476,906 -> 478,459 (+1,553)
- cut-merged 고리 8개, 점 수 47~128 (절단선 폭증 없음)
- tsc --noEmit 0

남은 0.8%: _Corridor_Region.ts simplifyLoop 의 마디 하한 0.25m 가 셀(0.06~0.2m)보다
커서 경계를 조금 더 안쪽으로 당기는 것으로 보인다. 별도 항목으로 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:38:46 +09:00
co-authored by Claude Opus 5
parent 5118482767
commit 3a5680fd76
2 changed files with 90 additions and 24 deletions
+7 -1
View File
@@ -189,7 +189,13 @@ function fnv1a(text: string): string {
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
const BUILD_VERSION = 83;
// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어
// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**.
// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
// 가르고 **닫힌 고리만** 담는다.
const BUILD_VERSION = 84;
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
+83 -23
View File
@@ -249,38 +249,95 @@ function traceMaskLoops(
if (!on(row, col - 1)) push(row + 1, col, row, col);
}
}
/**
* 갈림목에서 **가장 오른쪽으로 도는 변**을 고른다 — 변은 마스크된 셀을 왼쪽에 두고
* 담기므로(반시계), 오른손 규칙을 지켜야 두 덩어리가 맞닿은 자리에서 서로 다른
* 고리로 갈린다. 아무 변이나 집으면(`pop()`) walk가 남의 고리로 새 나가 막다른
* 길에 빠지고, 그 **열린 사슬이 고리로 담긴다** — 그 위에 point-in-polygon을 걸면
* 판정이 무너진다(2026-08-27 실측: 도려낸 셀의 18%가 되살릴 영역에서 빠졌다).
*/
const pickTurn = (
from: [number, number],
at: [number, number],
options: ReadonlyArray<[number, number]>,
): number => {
const inRow = at[0] - from[0];
const inCol = at[1] - from[1];
let best = 0;
let bestScore = -1;
options.forEach((to, index) => {
const outRow = to[0] - at[0];
const outCol = to[1] - at[1];
// 외적(왼쪽 양수) → 오른쪽 회전일수록 작다. 내적으로 직진/후진을 가른다.
const cross = inCol * outRow - inRow * outCol;
const dot = inCol * outCol + inRow * outRow;
// 우회전(3) > 직진(2) > 좌회전(1) > 되돌아가기(0).
const score = cross < 0 ? 3 : dot > 0 ? 2 : cross > 0 ? 1 : 0;
if (score > bestScore) {
bestScore = score;
best = index;
}
});
return best;
};
const loops: Array<Array<[number, number]>> = [];
for (const [start, list] of edges) {
while (list.length) {
for (const start of [...edges.keys()]) {
const list = edges.get(start);
while (list?.length) {
const head: [number, number] = [Math.floor(start / cols), start % cols];
const loop: Array<[number, number]> = [head];
let previous = head;
let current = list.pop() as [number, number];
let closed = false;
for (let guard = 0; guard < 100000; guard += 1) {
loop.push(current);
if (current[0] === head[0] && current[1] === head[1]) break;
if (current[0] === head[0] && current[1] === head[1]) {
closed = true;
break;
}
const next = edges.get(key(current[0], current[1]));
if (!next?.length) break;
current = next.pop() as [number, number];
const pick = next.length === 1 ? 0 : pickTurn(previous, current, next);
previous = current;
current = next.splice(pick, 1)[0];
}
if (loop.length >= 4) loops.push(loop);
// **닫힌 고리만** 쓴다 — 열린 사슬은 영역 판정에 쓸 수 없다. 위 규칙대로 돌면
// 정상 마스크에서는 언제나 닫힌다(안 닫히면 그 덩어리는 다음 시작점에서 다시 잡힌다).
if (closed && loop.length >= 4) loops.push(loop);
}
}
return loops;
}
/** 같은 방향으로 이어지는 격자 점은 버린다 — 직선 구간이 점 수천 개가 되지 않게. */
function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, number]> {
const kept: Array<[number, number]> = [];
/**
* 실좌표에서 **정말 일직선인** 점만 버린다 — 직선 구간이 점 수천 개가 되지 않게.
*
* 격자 인덱스로 재면 안 된다. 코리도 격자는 노선을 따라 **휘어 있어** 인덱스로 직선인
* 구간이 실좌표에서는 곡선이다. 인덱스 기준으로 지우면 폴리곤이 실제 마스크 **안쪽으로
* 코너를 잘라먹고**, 그 밖으로 삐져나온 셀은 되살릴 영역에서 빠져 구멍이 된다
* (2026-08-27 실측: 도려낸 셀의 18%, 전부 편거리가 큰 바깥 끝 열 40~46.
* 사용자: "유출구에 일부 성토부에 터짐 있음").
*/
const COLLINEAR_TOLERANCE_M = 0.001;
function dropCollinear(
loop: ReadonlyArray<[number, number, number]>,
): Array<[number, number, number]> {
if (loop.length < 3) return [...loop];
const kept: Array<[number, number, number]> = [];
for (let i = 0; i < loop.length; i += 1) {
const prev = loop[(i - 1 + loop.length) % loop.length];
const at = loop[i];
const next = loop[(i + 1) % loop.length];
if (
loop[i][0] - prev[0] === next[0] - loop[i][0] &&
loop[i][1] - prev[1] === next[1] - loop[i][1]
) {
continue;
const dx = next[0] - prev[0];
const dy = next[1] - prev[1];
const span = Math.hypot(dx, dy);
if (span > 1e-9) {
// prev→next 선분에서 잰 수직거리 — 이만큼도 안 벗어나면 없어도 같은 선이다.
const away = Math.abs(dx * (at[1] - prev[1]) - dy * (at[0] - prev[0])) / span;
if (away < COLLINEAR_TOLERANCE_M) continue;
}
kept.push(loop[i]);
kept.push(at);
}
return kept;
}
@@ -512,26 +569,29 @@ export function maskFillByPlanCurves(
};
}
traceMaskLoops(rows, cols, mask).forEach((raw) => {
const loop = dropCollinear(raw);
if (loop.length < 4 || !planes.length) return;
const meanRow = loop.reduce((sum, [row]) => sum + row, 0) / loop.length;
if (raw.length < 4 || !planes.length) return;
const meanRow = raw.reduce((sum, [row]) => sum + row, 0) / raw.length;
const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))];
const plane = planes.reduce((best, item) =>
Math.abs(item.chainage - chainage) < Math.abs(best.chainage - chainage) ? item : best,
);
const z = plane.planZ + MERGED_LIFT_M;
// **실좌표로 먼저 옮기고** 다듬는다 — 격자 인덱스로 다듬으면 휜 구간에서 코너를
// 잘라먹어 그 밖 셀이 되살릴 영역에서 빠진다.
const loop = dropCollinear(
raw.map(([row, col]) => {
const [x, y] = point(row, col);
return [x, y, z] as [number, number, number];
}),
);
if (loop.length < 4) return;
merged.push({
setChainageM: plane.chainage,
source: "cut-merged",
role: plane.role,
side,
planZ: z,
loops: [
loop.map(([row, col]) => {
const [x, y] = point(row, col);
return [x, y, z] as [number, number, number];
}),
],
loops: [loop],
});
});
const wall = buildWall(ribbon, wallLinesBySide.get(ribbon.side) ?? []);