diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 174cfb70..2ff29dc0 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -106,9 +106,9 @@ function fnv1a(text: string): string { // 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). // ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 54 = 절취 측벽을 리본 메시에서 떼어 **별도 서피스**로 내고, 원지반 아래로 여유 -// 1m를 더 내린다(2026-08-27 사용자). -const BUILD_VERSION = 54; +// 55 = 측벽을 **실제로 잘린 선**(걸친 셀은 교점 직선, 통째 셀은 이웃 모서리)에 +// 세운다 — 종전 격자 계단은 면 경계와 최대 0.3m 어긋났다(2026-08-27 사용자). +const BUILD_VERSION = 55; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ 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 863d7ec1..8ca98b01 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -116,6 +116,12 @@ function ringCrossings(areas: ReadonlyArray, ring: ReadonlyArray, ring: ReadonlyArray, ring: ReadonlyArray, -): Corner[] | null { +): CellClip | null { const hits = ringCrossings(areas, ring); // 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데, // 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라 @@ -169,7 +175,7 @@ function clipCellByChord( }); } } - return kept.length >= 3 ? kept : null; + return kept.length >= 3 ? { kept, chord: [h0, h1] } : null; } /** @@ -281,38 +287,84 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu const SKIRT_MARGIN_M = 1; /** - * 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 윤곽을 z로 밀어내 성토면에서 원지반 + * 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 경계를 z로 밀어내 성토면에서 원지반 * 아래(`SKIRT_MARGIN_M`)까지 세운다. 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로 * 낸다 — UV·법선이 리본에 묶이지 않아 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. * - * ponytail: 벽은 격자 모서리를 타는 윤곽(계단 6cm × 0.2m)을 그대로 쓴다 — 걸친 셀 안에서 - * 실제 절단선은 교점 직선이라 최대 한 셀만큼 어긋난다. 화면에서 틈이 보이면 그때 - * 교점 직선으로 세우면 된다. + * 벽이 서는 자리는 **실제로 잘린 선**이다(2026-08-27 사용자 지적으로 정정): + * · 걸친 셀 → 그 셀을 자른 **교점 직선**(면 조각의 경계와 같은 선). + * · 통째로 지운 셀 → 이웃이 **안 지워진 셀**인 쪽의 셀 모서리. + * 종전에는 마스크 격자 윤곽(셀 계단)을 그대로 밀어내 면 경계와 최대 0.3m 어긋났다. */ -function pushSkirt( - loop: ReadonlyArray<[number, number]>, - ribbon: CorridorRibbon, - tris: number[], -): void { - const ground = ribbon.groundZ; - if (!ground) return; - const cols = ribbon.colCount; - /** 격자점 → [x, y, 서피스 z, 지반 z − 여유]. */ - const at = (row: number, col: number): [number, number, number, number] => { - const i = (row * cols + col) * 3; - return [ - ribbon.positions[i], - ribbon.positions[i + 1], - ribbon.positions[i + 2], - ground[row * cols + col] - SKIRT_MARGIN_M, - ]; - }; - for (let i = 0; i < loop.length - 1; i += 1) { - const [x1, y1, top1, bottom1] = at(loop[i][0], loop[i][1]); - const [x2, y2, top2, bottom2] = at(loop[i + 1][0], loop[i + 1][1]); - if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) continue; // 높이 0이면 벽이 없다. - tris.push(x1, y1, top1, x2, y2, top2, x2, y2, bottom2); - tris.push(x1, y1, top1, x2, y2, bottom2, x1, y1, bottom1); +class SkirtBuilder { + private readonly tris: number[] = []; + + constructor( + private readonly ribbon: CorridorRibbon, + private readonly ground: Float64Array, + ) {} + + /** 셀 안 상대좌표(a: 열, b: 행)로 원지반 표고를 이중선형 보간한다. */ + private groundAt(row: number, col: number, corner: Corner): number { + const cols = this.ribbon.colCount; + const g = (r: number, c: number): number => this.ground[r * cols + c]; + const a = Math.min(1, Math.max(0, corner.a - col)); + const b = Math.min(1, Math.max(0, corner.b)); + const top = g(row, col) + (g(row, col + 1) - g(row, col)) * a; + const bottom = g(row + 1, col) + (g(row + 1, col + 1) - g(row + 1, col)) * a; + return top + (bottom - top) * b - SKIRT_MARGIN_M; + } + + private quad( + x1: number, + y1: number, + top1: number, + bottom1: number, + x2: number, + y2: number, + top2: number, + bottom2: number, + ): void { + if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) return; // 높이 0이면 벽이 없다. + if (Math.hypot(x2 - x1, y2 - y1) < 1e-9) return; + this.tris.push(x1, y1, top1, x2, y2, top2, x2, y2, bottom2); + this.tris.push(x1, y1, top1, x2, y2, bottom2, x1, y1, bottom1); + } + + /** 걸친 셀 — 그 셀을 자른 교점 직선에 벽을 세운다. */ + addChord(row: number, col: number, chord: readonly [Corner, Corner]): void { + const [h0, h1] = chord; + this.quad( + h0.p[0], + h0.p[1], + h0.p[2], + this.groundAt(row, col, h0), + h1.p[0], + h1.p[1], + h1.p[2], + this.groundAt(row, col, h1), + ); + } + + /** 통째로 지운 셀 — 안 지워진 이웃 쪽 셀 모서리에 벽을 세운다. */ + addEdge(rowA: number, colA: number, rowB: number, colB: number): void { + const cols = this.ribbon.colCount; + const at = (row: number, col: number): [number, number, number, number] => { + const i = (row * cols + col) * 3; + return [ + this.ribbon.positions[i], + this.ribbon.positions[i + 1], + this.ribbon.positions[i + 2], + this.ground[row * cols + col] - SKIRT_MARGIN_M, + ]; + }; + const [x1, y1, top1, bottom1] = at(rowA, colA); + const [x2, y2, top2, bottom2] = at(rowB, colB); + this.quad(x1, y1, top1, bottom1, x2, y2, top2, bottom2); + } + + result(): Float64Array | null { + return this.tris.length ? new Float64Array(this.tris) : null; } } @@ -352,11 +404,13 @@ export function maskFillByPlanCurves( const cols = ribbon.colCount; if (rows < 2 || cols < 2) return ribbon; const mask = new Uint8Array((rows - 1) * (cols - 1)); + /** 교점 직선으로 잘린 셀 — 그 셀의 벽은 이미 섰으니 모서리 벽을 또 세우지 않는다. */ + const chorded = new Uint8Array((rows - 1) * (cols - 1)); let masked = 0; const tris: number[] = []; const uvs: number[] = []; // 측벽은 리본 메시가 아니라 **별도 서피스**로 나간다(2026-08-27 사용자). - const wallTris: number[] = []; + const skirt = ribbon.groundZ ? new SkirtBuilder(ribbon, ribbon.groundZ) : null; const startChainage = ribbon.chainages[0]; const point = (row: number, col: number): [number, number, number] => { const i = (row * cols + col) * 3; @@ -385,11 +439,11 @@ export function maskFillByPlanCurves( masked += 1; continue; } - const kept = clipCellByChord(areas, ring); + const clip = clipCellByChord(areas, ring); // 절단 실패는 **안쪽 면적 비율**로 가른다(2026-08-27 사용자: 면적 필터). // 절반 이상 잠긴 셀은 지운다 — 종전처럼 통째로 남기면 경계에 미세 메쉬가 // 줄줄이 남았다(실측: 좌 313·우 314셀, 4.50·2.17㎡). - if (!kept) { + if (!clip) { if (coverage(areas, ring) >= 0.5) { mask[row * (cols - 1) + col] = 1; masked += 1; @@ -397,7 +451,10 @@ export function maskFillByPlanCurves( continue; } mask[row * (cols - 1) + col] = 1; + chorded[row * (cols - 1) + col] = 1; masked += 1; + skirt?.addChord(row, col, clip.chord); + const kept = clip.kept; for (let k = 1; k < kept.length - 1; k += 1) { for (const corner of [kept[0], kept[k], kept[k + 1]]) { tris.push(corner.p[0], corner.p[1], corner.p[2]); @@ -408,6 +465,24 @@ export function maskFillByPlanCurves( } } if (!masked) return ribbon; + // 통째로 지운 셀은 **안 지워진 이웃** 쪽 모서리에만 벽을 세운다. 걸친 셀은 위에서 + // 교점 직선으로 이미 세웠고, 그 셀의 남은 조각이 모서리까지 덮으므로 건드리지 않는다. + if (skirt) { + const filled = (row: number, col: number): boolean => + row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 + ? mask[row * (cols - 1) + col] === 0 + : true; + for (let row = 0; row < rows - 1; row += 1) { + for (let col = 0; col < cols - 1; col += 1) { + const i = row * (cols - 1) + col; + if (!mask[i] || chorded[i]) continue; + if (filled(row - 1, col)) skirt.addEdge(row, col, row, col + 1); + if (filled(row + 1, col)) skirt.addEdge(row + 1, col + 1, row + 1, col); + if (filled(row, col - 1)) skirt.addEdge(row + 1, col, row, col); + if (filled(row, col + 1)) skirt.addEdge(row, col + 1, row + 1, col + 1); + } + } + } // 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다. // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; @@ -415,7 +490,6 @@ export function maskFillByPlanCurves( traceMaskLoops(rows, cols, mask).forEach((raw) => { const loop = dropCollinear(raw); if (loop.length < 4 || !planes.length) return; - pushSkirt(loop, ribbon, wallTris); const meanRow = loop.reduce((sum, [row]) => sum + row, 0) / loop.length; const chainage = ribbon.chainages[Math.min(rows - 1, Math.round(meanRow))]; const plane = planes.reduce((best, item) => @@ -436,7 +510,8 @@ export function maskFillByPlanCurves( ], }); }); - if (wallTris.length) walls.push({ side, positions: new Float64Array(wallTris) }); + const wall = skirt?.result(); + if (wall) walls.push({ side, positions: wall }); return { ...ribbon, cellMask: mask,