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:
@@ -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 {
|
||||
|
||||
@@ -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) ?? []);
|
||||
|
||||
Reference in New Issue
Block a user