From 753e8fcb44b081b99772e2e195f496f2c64481c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 26 Aug 2026 22:32:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=ED=88=AC=EC=98=81=20=EC=BB=A4?= =?UTF-8?q?=EB=B8=8C=20=EC=95=88=EC=AA=BD=20=EC=84=B1=ED=86=A0=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20=EC=85=80=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 빨간 투영선이 감싸는 자리의 성토면을 지운다. 지울 영역은 결국 **상단 평면 스케치가 덮는 XY 영역** 하나이고 투영선이 그 XY를 그대로 복사한 것이므로, 구조물 종류를 가리지 않고 한 가지 방법으로 자른다(사용자 확정). - Cut(신규): 격자 셀 중심·귀퉁이로 안팎을 가려 안쪽 셀은 마스크하고, 걸친 셀은 교점 두 개가 만드는 직선으로 잘라 바깥 조각만 삼각형으로 되살린다. 정점은 하나도 안 건드린다. 교점이 2개 미만이면 셀을 통째로 남긴다 — 모자라게 자를지언정 더 자르지 않는다. - Build: CorridorRibbon에 cellMask·trimTris·trimUvs 추가. - Mesh: 마스크된 셀의 삼각형을 빼고, 조각 삼각형을 뒤에 이어 붙인다. - Plan: BOX암거 커브의 **안쪽 끝도 박스 축**으로 쏜다(boxInner + castToToe). 종전엔 바깥만 박스 축이고 안쪽은 노선 흐름이라 가로대가 비스듬했다 — 평면 스케치의 횡단 방향은 박스가 놓인 전역 단방향이어야 한다(사용자 확정). - Corridor: 저장본에 마스크·조각 삼각형을 담고 BUILD_VERSION을 올린다. 검증(공용 브라우저 캐시 데이터 실측): - BOX 커브 가로대 각도차 0.00°(좌·우) — 전역 단방향 확인. 다른 세트는 노선 흐름대로 1.1~48.8°. - 걸친 셀에서 되살린 조각 면적이 기대치와 일치 — 우 2.07㎡ vs 2.07, 좌 4.60 vs 4.61. - 커브 밖을 지운 셀 0. 도로부·측구·절토 리본 행 수 불변(1768/1768/1768/15/26/23/13). 남은 것: 걸친 셀 1,230개 중 약 600개가 교점 부족으로 통째로 남아 경계에 잔여가 생긴다(우 2.09㎡·좌 4.36㎡). 더 잘리는 문제는 없다. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 50 +++- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 60 ++++- B05_Profile/B05_Profile_UI_Corridor_Cut.ts | 250 +++++++++++++++++++ B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 26 +- B05_Profile/B05_Profile_UI_Corridor_Plan.ts | 27 +- 5 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Cut.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 8f6d832a..9f19d786 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -30,14 +30,11 @@ export interface CorridorStructureHook { } /** - * 저장본 형식 버전 — 올리면 구 저장본을 버리고 한 번 다시 빌드한다. + * 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다. * - * ⚠ 저장본은 **입력 해시(측점·노선)로만** 무효화된다. 코리도 기하 코드를 고쳐도 - * 입력이 그대로면 옛 저장본이 계속 복원되어 **수정이 화면에 전혀 안 나타난다** - * (2026-08-26 반나절 허비). 기하를 건드렸으면 이 번호를 반드시 함께 올릴 것. + * 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다. * - * 3 = 탑뷰 투영선(slope-projected) + 구조물 구간 경계 행 + 파란 스케치 안쪽 기준을 - * 절·성토면 시작점으로 정정(노견 확장·측구 반영) — 2026-08-26. + * 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26. */ const ENVELOPE_VERSION = 3; @@ -50,6 +47,11 @@ interface CorridorEnvelope { colCount: number; chainages: number[]; positionsBase64: string; + /** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */ + cellMaskBase64?: string; + /** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */ + trimTrisBase64?: string; + trimUvsBase64?: string; }>; outline: CorridorBuildResult["outline"]; /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ @@ -93,7 +95,11 @@ function fnv1a(text: string): string { * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 * 섞어 두면 배포와 동시에 저장본이 만료된다. */ -const BUILD_VERSION = 40; // 세월교 날개·에이프런을 종단경사에 태움(2026-08-26). +// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정· +// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26). +// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로 +// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. +const BUILD_VERSION = 50; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { @@ -137,6 +143,22 @@ function base64FromFloats(values: Float64Array): string { return btoa(binary); } +function base64FromBytes(bytes: Uint8Array): string { + let binary = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)); + } + return btoa(binary); +} + +function bytesFromBase64(encoded: string): Uint8Array { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + function floatsFromBase64(encoded: string): Float64Array { const binary = atob(encoded); const bytes = new Uint8Array(binary.length); @@ -154,6 +176,13 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { colCount: ribbon.colCount, chainages: ribbon.chainages, positionsBase64: base64FromFloats(ribbon.positions), + ...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}), + ...(ribbon.trimTris && ribbon.trimUvs + ? { + trimTrisBase64: base64FromFloats(ribbon.trimTris), + trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))), + } + : {}), })), outline: build.outline, caps: build.caps, @@ -170,6 +199,13 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { colCount: ribbon.colCount, chainages: ribbon.chainages, positions: floatsFromBase64(ribbon.positionsBase64), + ...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}), + ...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64 + ? { + trimTris: floatsFromBase64(ribbon.trimTrisBase64), + trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer), + } + : {}), })), outline: envelope.outline, caps: envelope.caps ?? [], diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index d0c96f56..307709f9 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -48,6 +48,7 @@ export type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corrido import { buildStructurePatchRibbons } from "./B05_Profile_UI_Corridor_Carve"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; import { buildPlanCurves, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; +import { maskFillByPlanCurves } from "./B05_Profile_UI_Corridor_Cut"; export type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; /** 리본 하나 — rows[i]는 프레임 i의 단면 점(colCount개, 모델 x/y/z 평탄 배열). */ @@ -69,6 +70,19 @@ export interface CorridorRibbon { * 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다. */ positions: Float64Array; + /** + * 셀 마스크 — `(rowCount-1) × (colCount-1)`, 1이면 **그 셀은 안 그린다** + * (2026-08-26 투영 커브 안쪽 지우기). 정점은 그대로 두고 삼각형만 뺀다. + */ + cellMask?: Uint8Array; + /** + * 축 고정 커브(BOX암거)가 **걸친 셀**을 커브에서 잘라 되살린 조각 삼각형 + * (모델 좌표, 삼각형당 9). 격자로는 못 담는 비스듬한 경계를 여기로 메운다 + * (2026-08-26 사용자: 이원화하여 관리). + */ + trimTris?: Float64Array; + /** `trimTris`와 짝인 UV(삼각형당 6) — 빗금이 실척으로 이어지게 한다. */ + trimUvs?: Float32Array; } /** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */ @@ -111,20 +125,33 @@ const SUBDIVIDE_STEP_M = 0.2; */ const STRUCTURE_SPAN_STEP_M = SUBDIVIDE_STEP_M; +/** + * 투영 커브 안쪽 **성토면 도려내기** 적용 여부(2026-08-26 사용자). 끄면 커브만 얹고 + * 서피스는 그대로 둔다. 절토면은 대상이 아니다(사용자: 성토면만). + */ +const FILL_CUT_APPLY: boolean = true; + /** * 구조물 점유 구간의 시작·끝 누가거리 — 여기를 **행 경계로 강제**해야 탑뷰 투영 * 커브가 격자 모서리 위에 앉는다. 값은 `_Corridor_Plan.ts`의 비탈 커브 구간과 같은 * 출처(`structureSilhouettes`의 전/후 몫)를 쓴다 — 두 곳이 어긋나면 커브 끝이 다시 * 셀 중간에 뜬다. */ -function structureSpanBounds(crossSections: ReadonlyArray): Array<[number, number]> { - const spans: Array<[number, number]> = []; +interface StructureSpanRange { + side: CorridorSide; + from: number; + to: number; +} + +function structureSpanBounds(crossSections: ReadonlyArray): StructureSpanRange[] { + const spans: StructureSpanRange[] = []; for (const section of crossSections) { for (const silhouette of structureSilhouettes(section)) { - spans.push([ - section.chainage_m - silhouette.span.beforeM, - section.chainage_m + silhouette.span.afterM, - ]); + spans.push({ + side: silhouette.side, + from: section.chainage_m - silhouette.span.beforeM, + to: section.chainage_m + silhouette.span.afterM, + }); } } return spans; @@ -140,7 +167,7 @@ function rowFractions( spanM: number, steps: number, includeEnd: boolean, - spans: ReadonlyArray<[number, number]>, + spans: ReadonlyArray, ): number[] { const ts: number[] = []; for (let k = 0; k <= (includeEnd ? steps : steps - 1); k += 1) ts.push(k / steps); @@ -150,7 +177,7 @@ function rowFractions( const t = (chainageM - startChainageM) / spanM; if (t > 1e-9 && t < 1 - 1e-9) ts.push(t); }; - for (const [from, to] of spans) { + for (const { from, to } of spans) { if (to <= startChainageM || from >= endChainageM) continue; put(from); put(to); @@ -602,14 +629,21 @@ export function buildCorridor( ); } + // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 + // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. + // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. + // + // **자르기보다 먼저** 내야 한다 — 성토면을 먼저 도려내면 투영선이 z를 읽을 면이 + // 없어져 커브가 통째로 사라진다(2026-08-26). + const planCurves = buildPlanCurves(crossSections, structures, frameAt, outline, ribbons); + return { - ribbons, + // 커브 안쪽 성토면은 **셀 마스크**로 지운다 — 행을 잘라 내면 결국 노선 기준이 + // 되어 비스듬한 커브에서 어긋난다(2026-08-26 사용자 지적, `_Cut.ts` 머리말). + ribbons: FILL_CUT_APPLY ? maskFillByPlanCurves(ribbons, planCurves) : ribbons, outline, caps, structures, - // 탑뷰 투영 커브(2026-08-26 사용자) — 세트별 평면(구조물 상단 +5m)에 눕힌 - // 구조물·비탈 커브. 원본 비탈선은 코리도 바깥 윤곽(outline)을 그대로 읽는다. - // 리본을 함께 넘겨 파란 스케치 커브를 절·성토면에 얹은 빨간 투영선도 낸다. - planCurves: buildPlanCurves(crossSections, structures, frameAt, outline, ribbons), + planCurves, }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts new file mode 100644 index 00000000..ec65bb35 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -0,0 +1,250 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Cut.ts + * 탑뷰 투영 커브(빨간 선) **안쪽 성토면 지우기** — 2026-08-26 사용자. + * + * 방식: 정점은 **하나도 건드리지 않는다**. 격자 셀(사각형) 중 **중심이 커브 안에 + * 드는 셀만 마스크**해 그리기 단계에서 삼각형을 빼면 그 자리가 비워진다. + * + * 왜 이렇게(2026-08-26 사용자 지적): 리본은 `행 × 열` 직사각 격자라 한 행 안에 + * 들쭉날쭉한 구멍을 담지 못한다. 그래서 종전처럼 행을 잘라 내는 방식은 결국 + * **행 경계 = 노선 기준**으로 떨어져, BOX암거처럼 커브가 노선과 비스듬한 자리에서 + * 파란선 바깥까지 잘려 나갔다(실측: 좌측 커브 바깥 변은 200.37~201.91인데 구멍은 + * 200.18~202.11). 셀 마스크는 그 한계가 없다 — 리샘플·조각 이어붙이기도 필요 없다. + * + * **선택 영역은 하나다**(2026-08-26 사용자): 지울 자리는 결국 상단 평면 스케치가 + * 덮는 XY 영역이고, 투영선은 그 XY를 그대로 복사한 것(Z만 서피스에서 읽음)이다. + * 그래서 구조물 종류를 가리지 않고 **한 가지 방법**으로 자른다 — 종전에 노선 흐름 + * 커브와 축 고정 커브(BOX)를 나눠 다루려 했으나, 영역이 같은데 방법만 갈라 두면 + * 한쪽은 계단, 다른 쪽은 지그재그가 됐다. + * + * 클리핑은 **셀 하나를 직선 하나로** 자른다. 셀은 폭 6cm·길이 0.2m라 그 안에서 커브는 + * 사실상 직선이고, 볼록 사각형을 직선으로 자르면 결과도 볼록이라 부채꼴 삼각분할이 + * 항상 옳다. 다각형 대 다각형 클리핑을 시도했다가 되살릴 면적의 40%를 잃고 톱니가 + * 났던 전례가 있다(2026-08-26 실측) — 교점 두 개로 직선을 세우는 지금 방식이 그 함정을 + * 피한다. 교점이 2개가 아니면 셀을 **통째로 남긴다**(모자라게 자를지언정 더 자르지 않는다). + * + * 잘린 자리는 비워 둔다 — 구조물 솔리드가 들어앉아 메운다(2026-08-25 사용자 확정). + * 절토면·도로부·원지반은 건드리지 않는다(사용자: 성토면만). + * ========================================================================== */ + +import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; +import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; + +/** 커브 다각형 하나 — XY와 경계 상자(빠른 배제용). */ +interface CurveArea { + points: Array<[number, number]>; + minX: number; + maxX: number; + minY: number; + maxY: number; +} + +/** 광선 투사 내부 판정(XY). */ +function inside(area: CurveArea, x: number, y: number): boolean { + if (x < area.minX || x > area.maxX || y < area.minY || y > area.maxY) return false; + const points = area.points; + let hit = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + const [xi, yi] = points[i]; + const [xj, yj] = points[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +/** 셀 네 귀퉁이 — 링 순서(row,col) → (row,col+1) → (row+1,col+1) → (row+1,col). */ +interface Corner { + p: [number, number, number]; + /** 셀 안 상대좌표 — a: 열 방향(0~1), b: 행 방향(0~1). UV 보간에 쓴다. */ + a: number; + b: number; +} + +function areaOf(loop: ReadonlyArray<[number, number, number]>): CurveArea { + const points = loop.map((point) => [point[0], point[1]] as [number, number]); + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const [x, y] of points) { + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + return { points, minX, maxX, minY, maxY }; +} + +/** 셀 둘레와 커브 경계의 교점(둘레 진행 순서). */ +function ringCrossings(areas: ReadonlyArray, ring: ReadonlyArray): Corner[] { + const hits: Corner[] = []; + for (let i = 0; i < ring.length; i += 1) { + const from = ring[i]; + const to = ring[(i + 1) % ring.length]; + const rx = to.p[0] - from.p[0]; + const ry = to.p[1] - from.p[1]; + const ts: number[] = []; + for (const area of areas) { + const points = area.points; + for (let k = 0, j = points.length - 1; k < points.length; j = k, k += 1) { + const sx = points[k][0] - points[j][0]; + const sy = points[k][1] - points[j][1]; + const denominator = rx * sy - ry * sx; + if (Math.abs(denominator) < 1e-12) continue; + const dx = points[j][0] - from.p[0]; + const dy = points[j][1] - from.p[1]; + const t = (dx * sy - dy * sx) / denominator; + const u = (dx * ry - dy * rx) / denominator; + if (t >= -1e-9 && t <= 1 + 1e-9 && u >= 0 && u <= 1) ts.push(Math.min(1, Math.max(0, t))); + } + } + ts.sort((p, q) => p - q); + for (const t of ts) { + const last = hits[hits.length - 1]; + const point: Corner = { + p: [from.p[0] + rx * t, from.p[1] + ry * t, from.p[2] + (to.p[2] - from.p[2]) * t], + a: from.a + (to.a - from.a) * t, + b: from.b + (to.b - from.b) * t, + }; + // 같은 자리 중복(모서리 통과) 제거. + if (last && Math.hypot(last.p[0] - point.p[0], last.p[1] - point.p[1]) < 1e-6) continue; + hits.push(point); + } + } + return hits; +} + +/** + * 셀을 교점 두 개가 만드는 **직선 하나로** 자르고, 커브 **바깥** 쪽 조각을 돌려준다. + * 교점이 2개가 아니면 null — 부르는 쪽이 셀을 통째로 남긴다. + */ +function clipCellByChord( + areas: ReadonlyArray, + ring: ReadonlyArray, +): Corner[] | null { + const hits = ringCrossings(areas, ring); + // 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데, + // 그때도 **처음과 마지막**을 이으면 셀 안 경계와 거의 같다(셀이 0.2m × 6cm라 + // 그 안에서 커브는 사실상 직선). 2개일 때만 자르면 3분의 2가 통째로 남아 경계가 + // 다시 지그재그가 됐다(2026-08-26 실측: 걸친 셀 1,230개 중 816개가 미절단). + if (hits.length < 2) return null; + const h0 = hits[0]; + const h1 = hits[hits.length - 1]; + const lx = h1.p[0] - h0.p[0]; + const ly = h1.p[1] - h0.p[1]; + if (Math.hypot(lx, ly) < 1e-9) return null; + /** 직선 왼쪽/오른쪽 부호. */ + const sideOf = (x: number, y: number): number => lx * (y - h0.p[1]) - ly * (x - h0.p[0]); + // 바깥 쪽 부호 — 커브 밖인 귀퉁이로 정한다. + let keepSign = 0; + for (const corner of ring) { + if (areas.some((area) => inside(area, corner.p[0], corner.p[1]))) continue; + const value = sideOf(corner.p[0], corner.p[1]); + if (Math.abs(value) > 1e-9) { + keepSign = Math.sign(value); + break; + } + } + if (!keepSign) return null; + // 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다. + const kept: Corner[] = []; + for (let i = 0; i < ring.length; i += 1) { + const from = ring[i]; + const to = ring[(i + 1) % ring.length]; + const sFrom = sideOf(from.p[0], from.p[1]) * keepSign; + const sTo = sideOf(to.p[0], to.p[1]) * keepSign; + if (sFrom >= -1e-9) kept.push(from); + if ((sFrom > 0 && sTo < 0) || (sFrom < 0 && sTo > 0)) { + const t = sFrom / (sFrom - sTo); + kept.push({ + p: [ + from.p[0] + (to.p[0] - from.p[0]) * t, + from.p[1] + (to.p[1] - from.p[1]) * t, + from.p[2] + (to.p[2] - from.p[2]) * t, + ], + a: from.a + (to.a - from.a) * t, + b: from.b + (to.b - from.b) * t, + }); + } + } + return kept.length >= 3 ? kept : null; +} + +/** + * 성토 리본에 **셀 마스크**(+ 축 고정 커브가 걸친 셀의 조각 삼각형)를 달아 돌려준다. + * 원본 정점은 안 건드린다. 아무것도 안 걸리면 리본을 그대로 통과시킨다. + */ +export function maskFillByPlanCurves( + ribbons: ReadonlyArray, + curves: ReadonlyArray, +): CorridorRibbon[] { + const areasBySide = new Map(); + for (const curve of curves) { + if (curve.source !== "slope-projected") continue; + const bucket = areasBySide.get(curve.side) ?? []; + curve.loops.forEach((loop) => { + if (loop.length >= 4) bucket.push(areaOf(loop)); + }); + areasBySide.set(curve.side, bucket); + } + + return ribbons.map((ribbon) => { + const areas = areasBySide.get(ribbon.side); + if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon; + const rows = ribbon.chainages.length; + const cols = ribbon.colCount; + if (rows < 2 || cols < 2) return ribbon; + const mask = new Uint8Array((rows - 1) * (cols - 1)); + let masked = 0; + const tris: number[] = []; + const uvs: number[] = []; + const startChainage = ribbon.chainages[0]; + const point = (row: number, col: number): [number, number, number] => { + const i = (row * cols + col) * 3; + return [ribbon.positions[i], ribbon.positions[i + 1], ribbon.positions[i + 2]]; + }; + const uvOf = (row: number, corner: Corner): [number, number] => { + const chainage = + ribbon.chainages[row] + (ribbon.chainages[row + 1] - ribbon.chainages[row]) * corner.b; + return [(chainage - startChainage) / 2, corner.a / Math.max(1, cols - 1)]; + }; + for (let row = 0; row < rows - 1; row += 1) { + for (let col = 0; col < cols - 1; col += 1) { + const ring: Corner[] = [ + { p: point(row, col), a: col, b: 0 }, + { p: point(row, col + 1), a: col + 1, b: 0 }, + { p: point(row + 1, col + 1), a: col + 1, b: 1 }, + { p: point(row + 1, col), a: col, b: 1 }, + ]; + // 귀퉁이가 하나도 안쪽이 아니면 그대로 둔다. + const inCount = ring.filter((corner) => + areas.some((area) => inside(area, corner.p[0], corner.p[1])), + ).length; + if (inCount === 0) continue; + if (inCount === 4) { + mask[row * (cols - 1) + col] = 1; + masked += 1; + continue; + } + const kept = clipCellByChord(areas, ring); + // 교점이 2개가 아니면 셀을 통째로 남긴다 — 모자라게 자를지언정 더 자르지 않는다. + if (!kept) continue; + mask[row * (cols - 1) + col] = 1; + masked += 1; + 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]); + const [u, v] = uvOf(row, corner); + uvs.push(u, v); + } + } + } + } + if (!masked) return ribbon; + return { + ...ribbon, + cellMask: mask, + ...(tris.length ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } : {}), + }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index 6a1d6a12..1a927119 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -133,8 +133,12 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff } } const indices: number[] = []; + // 셀 마스크가 선 자리는 삼각형을 빼 그 자리를 비운다 — 투영 커브 안쪽 지우기 + // (2026-08-26). 정점은 그대로 두므로 남는 면의 형상은 하나도 안 바뀐다. + const mask = ribbon.cellMask; for (let row = 0; row < rowCount - 1; row += 1) { for (let col = 0; col < cols - 1; col += 1) { + if (mask?.[row * (cols - 1) + col]) continue; const a = row * cols + col; const b = a + 1; const c = a + cols; @@ -142,9 +146,27 @@ function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.Buff indices.push(a, c, b, b, c, d); } } + // 축 고정 커브가 걸친 셀은 격자로 못 담는다 — 잘라 되살린 조각 삼각형을 뒤에 붙인다. + const trim = ribbon.trimTris; + const trimCount = trim ? trim.length / 3 : 0; + const allPositions = new Float32Array((rowCount * cols + trimCount) * 3); + const allUvs = new Float32Array((rowCount * cols + trimCount) * 2); + allPositions.set(positions); + allUvs.set(uvs); + if (trim) { + const base = rowCount * cols; + for (let v = 0; v < trimCount; v += 1) { + allPositions[(base + v) * 3] = trim[v * 3] - origin.cx; + allPositions[(base + v) * 3 + 1] = trim[v * 3 + 2] - origin.cz; + allPositions[(base + v) * 3 + 2] = -(trim[v * 3 + 1] - origin.cy); + allUvs[(base + v) * 2] = ribbon.trimUvs?.[v * 2] ?? 0; + allUvs[(base + v) * 2 + 1] = ribbon.trimUvs?.[v * 2 + 1] ?? 0; + if (v % 3 === 2) indices.push(base + v - 2, base + v - 1, base + v); + } + } const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geometry.setAttribute("position", new THREE.BufferAttribute(allPositions, 3)); + geometry.setAttribute("uv", new THREE.BufferAttribute(allUvs, 2)); geometry.setIndex(indices); geometry.computeVertexNormals(); return geometry; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index 77d76fea..b5ee60fb 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -48,6 +48,12 @@ export interface PlanCurve { planZ: number; /** 모델 좌표 닫힌 고리들(z는 전부 planZ). */ loops: Array>; + /** + * 가로 방향이 **노선이 아니라 구조물 축에 고정**된 커브(BOX암거). 커브가 코리도 + * 격자와 비스듬해 셀 마스크만으로는 경계가 지그재그가 된다 — 절단 쪽에서 이 커브만 + * 골라 셀을 커브로 쪼갠다(2026-08-26 사용자: 이원화하여 관리). + */ + axisLocked?: boolean; } /** 구조물 상단에서 투영 평면을 띄우는 높이(m) — 2026-08-26 사용자. */ @@ -364,6 +370,7 @@ export function buildPlanCurves( side, planZ, loops, + ...(boxAxis ? { axisLocked: true } : {}), }); }; @@ -430,13 +437,31 @@ export function buildPlanCurves( // 대신 고정 방향으로 쏜 선이 성토 끝선과 만나는 자리를 바깥 끝으로 삼는다 — // 끝은 여전히 실제 성토 끝선 위다(2026-08-26 사용자). const boxToe = boxAxis ? nearToe(outline, side, section.chainage_m) : null; + /** + * BOX암거는 **안쪽 끝도** 박스 축 위여야 한다(2026-08-26 사용자: 평면 스케치의 + * 횡단 방향은 박스가 놓인 **전역 단방향**이고 노선 흐름을 타면 안 된다). + * 바깥 끝처럼, 고정 축으로 쏜 선이 **성토면 안쪽 끝선**과 만나는 자리를 쓴다 — + * 한쪽만 축을 맞추면 가로대가 행을 가로질러 비스듬해진다. + */ + const boxInner = boxAxis + ? outline.chainages + .filter((chainageM) => Math.abs(chainageM - section.chainage_m) <= TOE_WINDOW_M) + .map((chainageM) => { + const routeFrame = routeFrameOf(chainageM); + const edge = at(routeFrame, roadEdge); + const start = surface.innerAt(chainageM, side, edge.x, edge.y) ?? edge; + return [start.x, start.y] as [number, number]; + }) + : null; /** 같은 행을 절·성토면 표고에 얹은 것 — 빨간 투영선(2026-08-26 사용자). */ const projected: Array<[[number, number, number], [number, number, number]] | null> = []; outline.chainages.forEach((chainageM, index) => { if (chainageM < from - 1e-9 || chainageM > to + 1e-9) return; const frame = frameOf(chainageM); const edgeXY = at(frame, roadEdge); - const surfaceStart = surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y); + const surfaceStart = boxInner + ? castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxInner) + : surface.innerAt(chainageM, side, edgeXY.x, edgeXY.y); let outerXY: PXY; if (boxToe) { const hit = castToToe(frame.cx, frame.cy, frame.leftX * sign, frame.leftY * sign, boxToe);