diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 13b4d1cb..e7ef3041 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -99,8 +99,9 @@ function fnv1a(text: string): string { // 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). // ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. -// 51 = 절단 실패 셀을 안쪽 면적 비율로 가른다 — 경계 잔여 메쉬 제거(2026-08-27). -const BUILD_VERSION = 51; +// 52 = 날개벽 사다리꼴을 절취 영역에 합치고, 잘린 자리 윤곽을 새 데이텀(+3m)에 +// `cut-merged` 커브로 낸다(2026-08-27). +const BUILD_VERSION = 52; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 307709f9..94f3d8a4 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -637,13 +637,15 @@ export function buildCorridor( // 없어져 커브가 통째로 사라진다(2026-08-26). const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons); + // 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이 + // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). + // 잘린 자리 윤곽(`cut-merged`)은 새 데이텀에 얹어 기존 커브 **뒤에 덧붙인다**. + const cut = FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : null; return { - // 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이 - // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). - ribbons: FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : ribbons, + ribbons: cut ? cut.ribbons : ribbons, outline, caps, structures, - planCurves, + planCurves: cut ? [...planCurves, ...cut.curves] : planCurves, }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index 0f96e32c..dab0cb47 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -198,25 +198,113 @@ function coverage(areas: ReadonlyArray, ring: ReadonlyArray): return hits / (STEPS * STEPS); } +/** 절취 영역이 되는 커브 — 비탈 투영선과 **날개벽 사다리꼴**의 합집합(2026-08-27 사용자). */ +const CUT_SOURCES: ReadonlyArray = ["slope-projected", "wing-box"]; + +/** 병합 커브를 올릴 **새 데이텀** — 그 세트 평면 대비 이만큼 위(2026-08-27 사용자). */ +const MERGED_LIFT_M = 3; + +/** 세트 평면 — 병합 커브를 어느 높이·어느 유입유출로 낼지 정한다. */ +interface SetPlane { + chainage: number; + planZ: number; + role: PlanCurve["role"]; +} + +/** + * 마스크된 셀 덩어리의 **격자 윤곽**을 고리로 추적한다 — 실제로 잘리는 자리다. + * 셀 하나가 6cm × 0.2m라 계단이 그 크기를 넘지 않는다. + */ +function traceMaskLoops( + rows: number, + cols: number, + mask: Uint8Array, +): Array> { + const on = (row: number, col: number): boolean => + row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 && mask[row * (cols - 1) + col] === 1; + const key = (row: number, col: number): number => row * cols + col; + const edges = new Map>(); + const push = (fromRow: number, fromCol: number, toRow: number, toCol: number): void => { + const list = edges.get(key(fromRow, fromCol)) ?? []; + list.push([toRow, toCol]); + edges.set(key(fromRow, fromCol), list); + }; + // 마스크된 셀에서 **이웃이 안 마스크된 쪽 모서리**만 담는다 — 링 방향을 맞춰 담아야 + // 이어 붙일 때 한 방향으로 돈다. + for (let row = 0; row < rows - 1; row += 1) { + for (let col = 0; col < cols - 1; col += 1) { + if (!on(row, col)) continue; + if (!on(row - 1, col)) push(row, col, row, col + 1); + if (!on(row, col + 1)) push(row, col + 1, row + 1, col + 1); + if (!on(row + 1, col)) push(row + 1, col + 1, row + 1, col); + if (!on(row, col - 1)) push(row + 1, col, row, col); + } + } + const loops: Array> = []; + for (const [start, list] of edges) { + while (list.length) { + const head: [number, number] = [Math.floor(start / cols), start % cols]; + const loop: Array<[number, number]> = [head]; + let current = list.pop() as [number, number]; + for (let guard = 0; guard < 100000; guard += 1) { + loop.push(current); + if (current[0] === head[0] && current[1] === head[1]) break; + const next = edges.get(key(current[0], current[1])); + if (!next?.length) break; + current = next.pop() as [number, number]; + } + if (loop.length >= 4) loops.push(loop); + } + } + return loops; +} + +/** 같은 방향으로 이어지는 격자 점은 버린다 — 직선 구간이 점 수천 개가 되지 않게. */ +function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, number]> { + const kept: Array<[number, number]> = []; + for (let i = 0; i < loop.length; i += 1) { + const prev = loop[(i - 1 + loop.length) % loop.length]; + 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; + } + kept.push(loop[i]); + } + return kept; +} + /** * 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다. * 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다. + * + * 절취 영역은 비탈 투영선 ∪ 날개벽 사다리꼴이고, 실제로 잘린 자리를 **병합 커브** + * (`cut-merged`)로 함께 돌려준다 — 기존 커브는 그대로 두고 새 데이텀(+3m)에 얹는다. */ export function maskFillByPlanCurves( ribbons: ReadonlyArray, curves: ReadonlyArray, -): CorridorRibbon[] { +): { ribbons: CorridorRibbon[]; curves: PlanCurve[] } { const areasBySide = new Map(); + const planesBySide = new Map(); for (const curve of curves) { - if (curve.source !== "slope-projected") continue; + if (!CUT_SOURCES.includes(curve.source)) continue; const bucket = areasBySide.get(curve.side) ?? []; curve.loops.forEach((loop) => { if (loop.length >= 4) bucket.push(areaOf(loop)); }); areasBySide.set(curve.side, bucket); + const planes = planesBySide.get(curve.side) ?? []; + if (!planes.some((plane) => plane.chainage === curve.setChainageM)) { + planes.push({ chainage: curve.setChainageM, planZ: curve.planZ, role: curve.role }); + } + planesBySide.set(curve.side, planes); } - return ribbons.map((ribbon) => { + const merged: PlanCurve[] = []; + const cut = ribbons.map((ribbon) => { const areas = areasBySide.get(ribbon.side); if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon; const rows = ribbon.chainages.length; @@ -277,10 +365,38 @@ export function maskFillByPlanCurves( } } if (!masked) return ribbon; + // 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다. + // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. + const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; + const planes = planesBySide.get(ribbon.side) ?? []; + 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; + 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; + 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]; + }), + ], + }); + }); return { ...ribbon, cellMask: mask, ...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}), }; }); + return { ribbons: cut, curves: merged }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 1a927119..9359016d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -44,6 +44,8 @@ const PLAN_CURVE_COLORS: Record = { "wing-box": 0xff8c1a, // 스케치 파란 커브를 절·성토면에 얹은 투영선 — 빨강(2026-08-26 사용자 지정). "slope-projected": 0xff3b30, + // 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 새 데이텀(+3m)에 얹어 라임으로 낸다. + "cut-merged": 0x84cc16, }; /** 유출측 파선 눈금(m) — 유입은 실선이다. */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index b5ee60fb..a534c915 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -35,7 +35,10 @@ export type PlanCurveSource = | "wing-box" /** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선** * (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */ - | "slope-projected"; + | "slope-projected" + /** 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 비탈 투영선 ∪ 날개벽 사다리꼴을 합쳐 + * 셀 마스크에서 되짚은 커브. **현 평면 대비 +3m 새 데이텀**에 얹는다. */ + | "cut-merged"; /** 투영 커브 한 벌 — 한 구조물 세트의 한 측(유입 또는 유출), 한 종류. */ export interface PlanCurve {