From 3a5680fd763cbe2cd25b55210c8ca696d33d66a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 27 Aug 2026 22:38:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=9E=98=EB=A6=B0=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EC=9C=A4=EA=B3=BD=EC=9D=84=20=EC=8B=A4=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=EC=97=90=EC=84=9C=20=EB=8B=A4=EB=93=AC=EC=96=B4=20?= =?UTF-8?q?=EC=84=B1=ED=86=A0=EB=B6=80=20=ED=84=B0=EC=A7=90=EC=9D=84=20?= =?UTF-8?q?=EB=A7=89=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유출구 쪽 성토부에 성토면도 지형도 없는 검은 쐐기가 남던 문제(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) --- B05_Profile/B05_Profile_UI_Corridor.ts | 8 +- B05_Profile/B05_Profile_UI_Corridor_Cut.ts | 106 ++++++++++++++++----- 2 files changed, 90 insertions(+), 24 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 4125a77a..a4b0c292 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index 4195e269..07f4fc5d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -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> = []; - 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) ?? []);