From a7561810673065cd72b9fc29c58fa7b4b47da445 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 27 Aug 2026 20:02:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=EB=B3=80=ED=98=95=20=EC=84=B1=ED=86=A0=EB=A9=B4=EC=9D=84=20?= =?UTF-8?q?=EC=A0=88=EC=B7=A8=20=EC=98=81=EC=97=AD=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=B1=84=EC=9A=B0=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=98=EB=9D=BC=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세월교 날개 사다리꼴이 안 채워지고, 채운 판이 절취 영역 밖으로 삐져나오던 문제. 원인은 패치 리본이 "노선 프레임 수직 행 × 소유 측점 단면 한 장" 스윕이라 비스듬한 날개 부채꼴과 방향·범위가 안 맞는 것이었다. 세월교 전용으로 고친다 (`section.ford` — BOX암거·기슭막이는 종전 경로 그대로). - 종방향: `wingSpanOnRoute` 신설. 날개 고리 점을 frameAt로 0.25m 간격 훑어 노선에 실투영한다. 종전 직선 전방 투영은 곡선+먼 편거리(-7.85m)에서 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다. 범위는 점유 구간 ±5m로 묶는다. - 횡방향: `wingRangeAt`이 행 횡단선 × 주황 고리 교점의 안쪽·바깥쪽 끝을 낸다. 바깥은 `extendRunTo`, 안쪽은 `prependRunTo`(노견 클램프). 지반 핀·스커트 유지. - `clipRunBelow`: 바닥판 상단 Z(ford slabTopElevation)를 넘는 점을 지운다. 종단 shift 반영, 천장 지나는 마디에 교차점 삽입. - `patchClip`: 패치를 maskFillByPlanCurves에 한 번 더 통과시켜 **지우는 규칙의 반대**를 건다 — 절취 영역 밖 셀은 지우고, 걸친 셀은 clipCellByChord(inside)로 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유한다. - 700줄 초과로 날개 몫 도구를 _Corridor_Carve_Wing.ts로 분리(순수 이동). - `__corridorScene` 노출(검증용 레이캐스트 좌표 변환). - BUILD_VERSION 67 -> 79. 자체검증: 삐져나온 패치 셀 우 74->0(145셀 마스크+조각 198), 좌 지느러미 47->0. BOX암거·기슭막이 masked=0(무변화). 영역 안 구멍 우 66/98% -> 39/56%. typecheck 통과, tmp/tests 213 passed·7 skipped, 변경 파일 prettier. 남은 작업: 좌측 96% 구멍(slope-projected 전폭 절취 vs fillSegments 0.25m), BOX 패치 리본 축퇴 원인 확인. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 45 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 17 +- B05_Profile/B05_Profile_UI_Corridor_Carve.ts | 120 ++++-- .../B05_Profile_UI_Corridor_Carve_Wing.ts | 400 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Corridor_Cut.ts | 44 +- ...5_Profile_UI_Corridor_Station_Structure.ts | 8 + B05_Profile/B05_Profile_UI_Viewer.ts | 10 + 7 files changed, 594 insertions(+), 50 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 2758ba4f..e9ff7a21 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -116,9 +116,48 @@ function fnv1a(text: string): string { // (2026-08-27 사용자). // 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴 // 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자). -// 67 = 날개 구간 행은 벽 상단에서 설계 성토 물매로 지반까지 내려 긋는다 — 날개 -// 사다리꼴을 변형 성토면이 채운다(2026-08-27 사용자). -const BUILD_VERSION = 67; +// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인) +// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반 +// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계 +// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형 +// 성토면이 있어야 한다 — 삭제가 아니다). +// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서 +// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이 +// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복"). +// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다 +// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다), +// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`, +// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로. +// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토 +// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자). +// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라 +// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서 +// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자: +// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림"). +// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`, +// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로 +// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다 +// (2026-08-27 사용자: "좌측 스윕 더 가야 함"). +// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과 +// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.) +// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단 +// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로"). +// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`). +// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다 +// (2026-08-27 사용자). 안쪽은 안 자른다. +// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니 +// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로 +// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다 +// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지"). +// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거, +// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고 +// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다 +// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다). +// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은 +// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 — +// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측). +// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자). +const BUILD_VERSION = 79; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ 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 ae3600b5..cc37c22d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -62,6 +62,13 @@ export interface CorridorRibbon { * 절단 대상에서 빠진다(패치까지 자르면 구멍이 도로 뚫린다). */ patch?: boolean; + /** + * 패치를 **절취 영역 안쪽으로 클립**한다(2026-08-27 사용자: 삐져나오지도 벌어지지도 + * 않게). `maskFillByPlanCurves`가 기본 성토면을 지우는 규칙의 **정확한 반대**를 건다 — + * 영역 밖 셀은 지우고, 걸친 셀은 안쪽 조각만 남긴다. 두 면이 같은 경계를 공유하니 + * 겹침도 구멍도 안 생긴다. **세월교 전용** — 구조물별로 로직이 다르다. + */ + patchClip?: boolean; /** * rowCount × colCount × 3 (모델 좌표). * @@ -680,8 +687,16 @@ export function buildCorridor( const patchRibbons = FILL_PATCH_APPLY ? buildStructurePatchRibbons(crossSections, structures, frameAt, profileZ ?? null, planCurves) : []; + // 패치도 같은 커브로 한 번 더 훑는다 — 단 **반대 규칙**이다(`patchClip` 표식이 붙은 + // 세월교 패치만: 절취 영역 **안쪽만** 남긴다). 지우는 면과 채우는 면이 같은 경계를 + // 공유해야 삐져나오지도 벌어지지도 않는다(2026-08-27 사용자). 병합 커브·측벽은 + // 여기서 안 나온다 — 기본 성토면 몫이다. + const clippedPatches = + FILL_CUT_APPLY && patchRibbons.length + ? maskFillByPlanCurves(patchRibbons, planCurves).ribbons + : patchRibbons; return { - ribbons: [...(cut ? cut.ribbons : ribbons), ...patchRibbons], + ribbons: [...(cut ? cut.ribbons : ribbons), ...clippedPatches], outline, caps, structures, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts index 69f4bfd8..76b61438 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Carve.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve.ts @@ -36,6 +36,16 @@ import { import type { CorridorRibbon } from "./B05_Profile_UI_Corridor_Build"; import { buildGroundAt, type PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; import { groundInterpolator } from "../B06_Section/B06_Section_UI_Cross_Culvert_Solve"; +import { + clipRunBelow, + extendRunTo, + NEAR_LIMIT_M, + PATCH_WING_MARGIN_M, + prependRunTo, + wingRangeAt, + wingSpanOf, + wingSpanOnRoute, +} from "./B05_Profile_UI_Corridor_Carve_Wing"; import { unionQuadOf } from "./B05_Profile_UI_Corridor_Union"; import type { PXY, UnionQuad as FootprintQuad } from "./B05_Profile_UI_Corridor_Union"; @@ -46,10 +56,6 @@ const MIN_REACH_M = 0.05; * 스커트 게이트(`_Corridor_Skirt.ts` PATCH_TOE_TOLERANCE_M)와 같은 값·같은 이유다. */ const PATCH_TRIM_TOLERANCE_M = 0.5; -/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다. - * 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */ -const NEAR_LIMIT_M = 30; - /** 링 폴리곤의 편거리 양 끝 — 그 링이 평면에서 차지하는 폭. */ export function ringEdges( polygon: ReadonlyArray, @@ -220,41 +226,6 @@ export function assembleStripLoops( return loops; } -/** - * 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②). - * - * 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts` - * `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는 - * 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다. - * - * 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라 - * 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유). - */ -function wingSpanOf( - curves: ReadonlyArray, - chainageM: number, - side: "left" | "right", - frame: RouteFrame, -): { beforeM: number; afterM: number } { - // 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다. - const forwardX = -frame.leftY; - const forwardY = frame.leftX; - let before = 0; - let after = 0; - for (const curve of curves) { - if (curve.source !== "wing-box" || curve.side !== side) continue; - if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; - for (const loop of curve.loops) { - for (const [x, y] of loop) { - const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY; - if (s < 0) before = Math.max(before, -s); - else after = Math.max(after, s); - } - } - } - return { beforeM: before, afterM: after }; -} - /** * 구조물 **패치 리본**(2026-08-25 사용자 ④) — 구조물 때문에 기본 성토선에서 변형된 * 성토선은 투영으로 잘린 자리에 **다시 그려야 하는 대상**이다. B06 횡단 실루엣을 @@ -286,6 +257,13 @@ export function buildStructurePatchRibbons( * 지우는 영역과 채우는 영역이 같은 방향으로 선다. 세월교·배수관은 노선 프레임. */ const boxAxis = section.box ? section.frame.left_xy : null; + /** + * 날개 몫 스윕·트림은 **세월교 전용**이다(2026-08-27 사용자: "구조물별로 구분되어야 + * 함 — 세월교, 박스암거, 기슭막이"). 날개벽은 세월교와 BOX암거 두 곳에서 쓰지만 + * 평면 스케치를 내는 로직이 서로 다르고(`wingAxis` 유무), 기슭막이(배수관 세트)는 + * 날개 사다리꼴 자체가 없다. BOX·기슭막이는 종전 경로를 그대로 탄다. + */ + const isFord = section.ford != null; const frameOf = (chainageM: number): RouteFrame | null => { const frame = frameAt(chainageM); if (!frame || !boxAxis) return frame; @@ -351,9 +329,23 @@ export function buildStructurePatchRibbons( const spanFrom = section.chainage_m - silhouette.span.beforeM; const spanTo = section.chainage_m + silhouette.span.afterM; // 날개 사다리꼴 몫만큼 더 뻗는다(2026-08-27 사용자 ②) — 지우는 영역과 맞춘다. - const wing = wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame); - const from = Math.min(spanFrom, section.chainage_m - wing.beforeM); - const to = Math.max(spanTo, section.chainage_m + wing.afterM); + // 세월교는 **노선 실투영**으로 잰다 — 직선 전방 투영은 곡선 + 먼 편거리에서 + // 3m 어긋나 고리 앞부분이 범위 밖으로 밀려났다(2026-08-27 A안). BOX암거는 + // 종전 직선 투영 그대로 — 로직이 분리돼 있고 이미 채워져 있다. + const wing = isFord + ? wingSpanOnRoute(planCurves, section.chainage_m, silhouette.side, frameOf) + : wingSpanOf(planCurves, section.chainage_m, silhouette.side, ownFrame); + // 여유는 **구조물 점유 구간 기준** 5m다(측점 기준이 아니다 — 세월교 우측은 + // 점유 구간이 이미 측점에서 6.1m 앞까지 가 있다). 그 안에서 날개벽 끝점 + // 횡단선까지 스윕한다(2026-08-27 사용자). + const from = Math.max( + spanFrom - PATCH_WING_MARGIN_M, + Math.min(spanFrom, section.chainage_m - wing.beforeM), + ); + const to = Math.min( + spanTo + PATCH_WING_MARGIN_M, + Math.max(spanTo, section.chainage_m + wing.afterM), + ); if (!(to - from > 1e-6)) continue; const rowChainages: number[] = []; for (let at = from; at < to; at += 1) rowChainages.push(at); @@ -382,6 +374,7 @@ export function buildStructurePatchRibbons( chainages: buffer.map((row) => row.chainage), positions, patch: true, + ...(isFord ? { patchClip: true } : {}), }); } buffer = []; @@ -393,6 +386,14 @@ export function buildStructurePatchRibbons( flush(); continue; } + // 그 행이 주황 날개 스케치와 만나는 구간(안쪽·바깥쪽 끝) — 세월교 전용. + const wingRange = isFord + ? wingRangeAt(planCurves, section.chainage_m, silhouette.side, frame) + : null; + // 여기서 행을 끊지 않는다 — 스윕 범위가 이미 **날개벽 끝점 횡단선**까지로 + // 트림돼 있다(`wingSpanOnRoute` + `PATCH_WING_MARGIN_M`). 고리와 안 만나는 + // 중간 행까지 끊으면 구조물 구간과 날개 사이가 토막 난다(2026-08-27 사용자: + // "노선방향 스윕은 좋은데 조금 부족한 구간이 있다"). let points = source; // 벽 트림은 **옛 한 줄** 전용이다. 날개 몫으로 늘린 행도 트림하지 않는다 — // **날개벽 평면 스케치까지 변형 성토면에 포함**하는 게 맞다(2026-08-27 사용자): @@ -427,9 +428,7 @@ export function buildStructurePatchRibbons( // **직접 교차시켜 끝낸다**(빨간 교차선). 소유 측점 단면 한 장을 복사한 것이라 // · 지반이 올라오면 교차점까지 **자르고**(교차선 밖은 스커트가 수직 마감), // · 지반이 내려가면 마지막 구배 그대로 **연장**해 원본 성토 끝선까지 간다. - // 연장이 없으면 날개 사다리꼴 바깥변까지 2.4~4.5m 모자라 구멍이 남는다 - // (2026-08-27 실측: BOX 200.9 날개 표본 전량이 횡방향 밖). 규칙·한도는 원래 - // 성토면과 같다(`_Corridor_Station.ts` trimSlopeToGround). + // 이 지반 핀이 있어야 바깥 끝이 원지반에 물리고 스커트도 선다(2026-08-27 사용자). if (runReachesGround) { const ground = groundOfChainage(at); if (ground) { @@ -449,6 +448,37 @@ export function buildStructurePatchRibbons( } } } + // 주황 **날개 평면 스케치(`wing-box`)** 몫은 그 위에 **덧붙인다**(2026-08-27 + // 사용자: "스윕을 노선 기준으로 더 넓히고 주황 스케치로 트림" — 지우는 게 + // 아니라 그 자리에 변형 성토면이 **있어야** 한다). 지우는 쪽이 같은 고리로 + // 성토면을 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽이 거기까지 + // 못 가면 구멍이 남는다(실측: 세월교 149.7 날개 구멍 66~98%). + // 지반 핀보다 스케치가 더 바깥일 때만 늘린다 — 안쪽이면 지반 핀을 그대로 둔다. + if (wingRange) { + // 바깥 — 스케치 경계까지 설계 성토 물매로 덧이음. + if ((wingRange.outer - points[points.length - 1].offset_m) * sign > 1e-6) { + const extended = extendRunTo(points, wingRange.outer); + if (extended) points = extended; + } + // 안쪽 — 노견(확장점)보다 안쪽으로는 안 간다. 노면을 덮으면 안 된다. + const roadLimit = + silhouette.widenTo?.offset_m ?? silhouette.points[0]?.offset_m ?? points[0].offset_m; + const innerTarget = + (wingRange.inner - roadLimit) * sign < 0 ? roadLimit : wingRange.inner; + if ((points[0].offset_m - innerTarget) * sign > 1e-6) { + const prepended = prependRunTo(points, innerTarget); + if (prepended) points = prepended; + } + } + // 바닥판 상단 Z를 넘는 점은 지운다(2026-08-27 사용자 ①). + if (silhouette.floorTopZ != null) { + const shift = baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0; + points = clipRunBelow(points, silhouette.floorTopZ, shift); + if (points.length < 2) { + flush(); + continue; + } + } buffer.push({ chainage: at, frame, points }); } flush(); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts b/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts new file mode 100644 index 00000000..a16b9bf3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Carve_Wing.ts @@ -0,0 +1,400 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Carve_Wing.ts + * 패치 리본(변형 성토면)의 **날개 몫** 도구 — 스윕 범위 산출과 줄 자르기·늘리기. + * `_Corridor_Carve.ts`에서 잘라 냈다(700줄 제한, 2026-08-27). + * + * 지우는 쪽은 날개 사다리꼴(`wing-box` 고리)을 영역으로 도려내는데 + * (`_Corridor_Cut.ts` `CUT_SOURCES`) 채우는 쪽은 노선 프레임 사각격자 리본이라 + * 그대로는 그 부채꼴을 못 덮는다. 여기 도구들이 그 간극을 메운다. + * · 종방향 — `wingSpanOf`(직선 투영, BOX암거용) / `wingSpanOnRoute`(노선 실투영, 세월교용) + * · 횡방향 — `wingRangeAt`(행 횡단선 × 고리 교점의 안쪽·바깥쪽 끝) + * · 줄 손질 — `extendRunTo` / `prependRunTo` / `clipRunToRange` / `clipRunBelow` + * ========================================================================== */ + +import type { RouteFrame } from "./B05_Profile_UI_Corridor_Structures"; +import type { OffsetPoint } from "./B05_Profile_UI_Corridor_Station"; +import type { PlanCurve } from "./B05_Profile_UI_Corridor_Plan"; + +/** 행 횡단선에서 풋프린트를 볼 최대 반경(m) — 이보다 먼 사각형은 후보에서 뺀다. + * 횡단선은 무한 직선이라 헤어핀 건너편 구조물까지 얻어걸린다 — 반경으로 거른다. */ +export const NEAR_LIMIT_M = 30; + +/** + * 날개 사다리꼴(`wing-box`)이 **노선 방향으로 얼마나 더 뻗는가**(2026-08-27 사용자 ②). + * + * 원본 성토면을 지우는 영역은 `스트립 ∪ 날개 사다리꼴`인데(`_Corridor_Cut.ts` + * `CUT_SOURCES`) 되메우는 패치는 스트립만 덮어 날개 옆이 구멍으로 남았다. 지우는 + * 쪽과 같은 커브를 읽어 그 종방향 몫만큼 패치 행 범위를 넓힌다. + * + * 커브 점을 그 측점 프레임의 **전방 벡터**에 투영해 누가거리 몫을 잰다 — 구간이 수 m라 + * 노선 곡률은 무시한다(`_Corridor_Plan.ts`가 노견 편거리를 구간 내내 쓰는 것과 같은 이유). + */ +export function wingSpanOf( + curves: ReadonlyArray, + chainageM: number, + side: "left" | "right", + frame: RouteFrame, +): { beforeM: number; afterM: number } { + // 전방 = 좌향 벡터를 90° 돌린 것. 부호는 누가거리 증가 방향과 맞춘다. + const forwardX = -frame.leftY; + const forwardY = frame.leftX; + let before = 0; + let after = 0; + for (const curve of curves) { + if (curve.source !== "wing-box" || curve.side !== side) continue; + if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; + for (const loop of curve.loops) { + for (const [x, y] of loop) { + const s = (x - frame.cx) * forwardX + (y - frame.cy) * forwardY; + if (s < 0) before = Math.max(before, -s); + else after = Math.max(after, s); + } + } + } + return { beforeM: before, afterM: after }; +} + +/** 날개 고리를 노선에 실투영할 때 훑는 반경(m)과 간격(m) — 행 간격 1m보다 촘촘하다. */ +const WING_SPAN_SEARCH_M = 30; +const WING_SPAN_STEP_M = 0.25; + +/** + * 날개 몫으로 **더 스윕해 두는 여유**(m, 2026-08-27 사용자: "현재 기준보다 5m 양쪽으로 + * 늘려서 서피스를 만들고 날개벽 끝점 횡단선으로 트림"). 트림선이 이 안에 들어오므로 + * 화면 결과는 트림선까지다 — 여유는 트림선이 행 사이에 떨어져도 끊기지 않게 한다. + */ +export const PATCH_WING_MARGIN_M = 5; + +/** + * 날개 사다리꼴의 **노선 실투영** 종방향 범위(2026-08-27 사용자 A안). `wingSpanOf`는 + * 측점 프레임의 **직선** 전방 벡터에 투영해 곡률을 무시하는데, 세월교 날개는 편거리 + * -7.85m까지 나가 있어 그 근사가 3m 어긋난다(실측: 우측 고리 140.55~143.88 인데 + * 패치는 143.6부터 시작 — 고리 앞부분이 통째로 범위 밖이라 98% 구멍). + * + * 후보 누가거리를 실제 `frameAt`로 훑어 각 점의 **최근접 측점**을 찾는다. + * + * 재는 대상은 **고리 전체 점**이다. 날개벽 선 두 끝만 쓰면 좌측이 143.2~157.3 → + * 144.4~155.9로 **줄어든다**(2026-08-27 사용자: "좌측 스윕 더 가야 함") — 날개 + * 사다리꼴은 벽 끝점보다 성토 끝선 쪽으로 더 뻗기 때문이다(우측 실측: 벽 끝 142.12, + * 고리 끝 140.55). 트림은 구조물 점유 구간 ±`PATCH_WING_MARGIN_M`이 맡는다. + * BOX암거는 이 경로를 쓰지 않는다 — 로직이 분리돼 있고 이미 채워져 있다. + */ +export function wingSpanOnRoute( + curves: ReadonlyArray, + chainageM: number, + side: "left" | "right", + frameAt: (chainageM: number) => RouteFrame | null, +): { beforeM: number; afterM: number } { + const points: Array<[number, number]> = []; + for (const curve of curves) { + if (curve.source !== "wing-box" || curve.side !== side) continue; + if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; + for (const loop of curve.loops) for (const [x, y] of loop) points.push([x, y]); + } + if (!points.length) return { beforeM: 0, afterM: 0 }; + const candidates = routeCandidates(chainageM, frameAt); + if (!candidates.length) return { beforeM: 0, afterM: 0 }; + let before = 0; + let after = 0; + for (const [x, y] of points) { + const s = projectChainage(candidates, x, y) - chainageM; + if (s < 0) before = Math.max(before, -s); + else after = Math.max(after, s); + } + return { beforeM: before, afterM: after }; +} + +/** 측점 주변 후보 자리 — 고리 점을 노선에 실투영할 때 쓴다. */ +function routeCandidates( + chainageM: number, + frameAt: (chainageM: number) => RouteFrame | null, +): Array<{ at: number; cx: number; cy: number }> { + const candidates: Array<{ at: number; cx: number; cy: number }> = []; + for ( + let at = chainageM - WING_SPAN_SEARCH_M; + at <= chainageM + WING_SPAN_SEARCH_M; + at += WING_SPAN_STEP_M + ) { + const frame = frameAt(at); + if (frame) candidates.push({ at, cx: frame.cx, cy: frame.cy }); + } + return candidates; +} + +/** 점 하나의 최근접 누가거리. */ +function projectChainage( + candidates: ReadonlyArray<{ at: number; cx: number; cy: number }>, + x: number, + y: number, +): number { + let bestAt = candidates[0].at; + let bestDistance = Infinity; + for (const candidate of candidates) { + const distance = (x - candidate.cx) ** 2 + (y - candidate.cy) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + bestAt = candidate.at; + } + } + return bestAt; +} + +/** + * 그 행 횡단선이 **주황 날개 평면 스케치**(`wing-box` 고리)와 만나는 **안쪽·바깥쪽 + * 끝 편거리**(2026-08-27 사용자: "스윕 범위를 날개 평면 스케치까지 지정"). 안 만나면 null. + * + * 바깥만으로는 부족하다 — 세월교 좌측 고리는 편거리 +2.64부터인데 패치는 +4.15에서 + * 시작한다(실측). 고리 몸통 1.5m가 패치 **안쪽**이라 안쪽으로도 늘려야 덮인다. + * + * 지우는 쪽이 그 고리 영역을 통째로 도려내므로(`_Corridor_Cut.ts` `CUT_SOURCES`) + * 채우는 쪽도 같은 고리까지 뻗어야 구멍이 안 남는다. `wingSpanOf`가 **종방향** 몫을 + * 재는 것과 짝이 되는 **가로방향** 몫이다. + */ +export function wingRangeAt( + curves: ReadonlyArray, + chainageM: number, + side: "left" | "right", + frame: RouteFrame, +): { inner: number; outer: number } | null { + const sign = side === "left" ? 1 : -1; + let inner: number | null = null; + let outer: number | null = null; + for (const curve of curves) { + if (curve.source !== "wing-box" || curve.side !== side) continue; + if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; + for (const loop of curve.loops) { + for (let i = 0; i < loop.length - 1; i += 1) { + const [ax, ay] = loop[i]; + const [bx, by] = loop[i + 1]; + const ex = bx - ax; + const ey = by - ay; + const denominator = frame.leftX * ey - frame.leftY * ex; + if (Math.abs(denominator) < 1e-12) continue; + const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator; + if (t < -1e-9 || t > 1 + 1e-9) continue; + const o = + Math.abs(frame.leftX) > Math.abs(frame.leftY) + ? (ax + ex * t - frame.cx) / frame.leftX + : (ay + ey * t - frame.cy) / frame.leftY; + // 헤어핀 건너편 고리가 무한 직선에 얻어걸리는 걸 막는다(`outerAt`과 같은 이유). + if (Math.abs(o) > NEAR_LIMIT_M) continue; + if (o * sign <= 0) continue; + if (outer === null || (o - outer) * sign > 0) outer = o; + if (inner === null || (o - inner) * sign < 0) inner = o; + } + } + } + return outer === null || inner === null ? null : { inner, outer }; +} + +/** 첫 구배 그대로 `targetOffset`까지 **안쪽으로** 늘린 줄 — 못 늘리면 null. + * 벽 뒤로 성토면을 잇는 것이라 벽 솔리드에 묻힌다(벽 전면·유로는 안 담는다 — + * 2026-08-25 사용자 ②의 "관 구멍·L자 통로를 막지 마라"는 그대로 지킨다). */ +export function prependRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { + if (run.length < 2) return null; + const head = run[0]; + const next = run[1]; + const step = next.offset_m - head.offset_m; + if (Math.abs(step) < 1e-9) return null; + const slope = (next.elevation_m - head.elevation_m) / step; + return [ + { + offset_m: targetOffset, + elevation_m: head.elevation_m + slope * (targetOffset - head.offset_m), + }, + ...run, + ]; +} + +/** + * **세월교 바닥판 상단 Z를 넘는 점을 지운다**(2026-08-27 사용자 ①). 안쪽으로 늘린 + * 성토 물매가 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀는 걸 막는다. + * + * 줄은 안쪽→바깥 순이고 성토면은 바깥으로 내려가므로 천장 초과분은 **앞머리**다. + * 지우기만 하면 경계가 격자에 걸려 계단이 되므로, 천장을 지나는 마디에 **교차점을 + * 새로 꽂는다**. 비교는 종단 shift를 얹은 **실제 표고**로 한다 — 바닥판은 측점에 + * 고정된 구조물이라 shift와 함께 움직이지 않는다. + */ +export function clipRunBelow(run: OffsetPoint[], ceilingZ: number, shift: number): OffsetPoint[] { + const above = (point: OffsetPoint): boolean => point.elevation_m + shift > ceilingZ + 1e-9; + let start = 0; + while (start < run.length && above(run[start])) start += 1; + if (start === 0) return run; + if (start >= run.length) return []; + const outside = run[start - 1]; + const inside = run[start]; + const kept = run.slice(start); + const drop = outside.elevation_m - inside.elevation_m; + if (Math.abs(drop) > 1e-9) { + const ratio = (outside.elevation_m + shift - ceilingZ) / drop; + const offset = outside.offset_m + (inside.offset_m - outside.offset_m) * ratio; + if (Math.abs(offset - inside.offset_m) > 1e-6) { + kept.unshift({ offset_m: offset, elevation_m: ceilingZ - shift }); + } + } + return kept; +} + +/** 마지막 구배(= 설계 성토 물매) 그대로 `targetOffset`까지 늘린 줄 — 못 늘리면 null. */ +export function extendRunTo(run: OffsetPoint[], targetOffset: number): OffsetPoint[] | null { + if (run.length < 2) return null; + const tail = run[run.length - 1]; + const before = run[run.length - 2]; + const step = tail.offset_m - before.offset_m; + if (Math.abs(step) < 1e-9) return null; + const slope = (tail.elevation_m - before.elevation_m) / step; + return [ + ...run, + { + offset_m: targetOffset, + elevation_m: tail.elevation_m + slope * (targetOffset - tail.offset_m), + }, + ]; +} + +/** 날개 하나의 트림선 — 바깥선 폴리라인과 그 날개가 노선에서 차지하는 구간. */ +export interface WingTrimLine { + line: Array<[number, number]>; + fromM: number; + toM: number; +} + +/** 트림선이 모자랄 때 **끝 마디를 복사해 늘리는** 길이(m, 2026-08-27 사용자). */ +const WING_TRIM_EXTEND_M = 20; + +/** + * 주황 고리의 **바깥선**만 뽑아 양 끝을 늘린 트림선(2026-08-27 사용자: "주황색 + * 바깥선으로 잘라야 해 / 조금 모자라면 해당선을 복사해서 연장하고 트림"). + * + * `wingBoxLoops`가 내는 고리 차례는 [시작 링, 끝 링, 끝 링→끝선 교점, …성토 끝선…, + * 시작 링→끝선 교점, (닫기)]다. 바깥선 = 인덱스 2 ~ 끝-2 — 안쪽 날개벽 선은 뺀다. + * 고리 종방향 밖 행은 교점이 없어 트림이 안 걸리므로, 양 끝 마디 방향으로 + * `WING_TRIM_EXTEND_M`만큼 **연장한 점을 붙여** 그 행에서도 만나게 한다. + */ +export function wingOuterLines( + curves: ReadonlyArray, + chainageM: number, + side: "left" | "right", + frameAt: (chainageM: number) => RouteFrame | null, +): WingTrimLine[] { + const candidates = routeCandidates(chainageM, frameAt); + if (!candidates.length) return []; + const lines: WingTrimLine[] = []; + for (const curve of curves) { + if (curve.source !== "wing-box" || curve.side !== side) continue; + if (Math.abs(curve.setChainageM - chainageM) > 1e-6) continue; + for (const loop of curve.loops) { + if (loop.length < 5) continue; + const line: Array<[number, number]> = loop + .slice(2, loop.length - 1) + .map((point) => [point[0], point[1]]); + if (line.length < 2) continue; + const stretch = (near: [number, number], far: [number, number]): [number, number] => { + const dx = far[0] - near[0]; + const dy = far[1] - near[1]; + const length = Math.hypot(dx, dy); + if (length < 1e-9) return far; + return [ + far[0] + (dx / length) * WING_TRIM_EXTEND_M, + far[1] + (dy / length) * WING_TRIM_EXTEND_M, + ]; + }; + // 이 날개가 노선에서 차지하는 구간 — **행을 어느 날개 몫으로 볼지** 가른다. + const spans = line.map(([x, y]) => projectChainage(candidates, x, y)); + line.unshift(stretch(line[1], line[0])); + line.push(stretch(line[line.length - 2], line[line.length - 1])); + lines.push({ line, fromM: Math.min(...spans), toM: Math.max(...spans) }); + } + } + return lines; +} + +/** + * 그 행 횡단선이 **그 행을 맡은 날개**의 트림선과 만나는 편거리. 안 만나면 null. + * + * 날개는 **부재별로 구분한다**(2026-08-27 사용자: "구조물별로 구분되어야 함"). + * 세월교는 한 측에 유입·유출 날개가 각각 있어 트림선이 둘인데, 끝 마디를 20m 늘려 + * 놓아 서로의 구간까지 뻗는다. 그대로 합쳐서 가장 바깥을 고르면 유입 날개 선이 + * 유출 날개 행을 잘라 버린다 — 행마다 **누가거리가 가장 가까운 날개 하나만** 쓴다. + */ +export function wingTrimAt( + lines: ReadonlyArray, + side: "left" | "right", + frame: RouteFrame, + at: number, +): number | null { + if (!lines.length) return null; + let owner = lines[0]; + let bestGap = Infinity; + for (const entry of lines) { + const gap = Math.max(entry.fromM - at, at - entry.toM, 0); + if (gap < bestGap) { + bestGap = gap; + owner = entry; + } + } + const sign = side === "left" ? 1 : -1; + let outer: number | null = null; + { + const line = owner.line; + for (let i = 0; i < line.length - 1; i += 1) { + const [ax, ay] = line[i]; + const [bx, by] = line[i + 1]; + const ex = bx - ax; + const ey = by - ay; + const denominator = frame.leftX * ey - frame.leftY * ex; + if (Math.abs(denominator) < 1e-12) continue; + const t = (frame.leftX * (ay - frame.cy) - frame.leftY * (ax - frame.cx)) / denominator; + if (t < -1e-9 || t > 1 + 1e-9) continue; + const o = + Math.abs(frame.leftX) > Math.abs(frame.leftY) + ? (ax + ex * t - frame.cx) / frame.leftX + : (ay + ey * t - frame.cy) / frame.leftY; + if (Math.abs(o) > NEAR_LIMIT_M) continue; + if (o * sign <= 0) continue; + if (outer === null || (o - outer) * sign > 0) outer = o; + } + } + return outer; +} + +/** + * 줄을 편거리 구간 `[from, to]`(부호 기준 안쪽→바깥)만 남기고 **자른다**. 경계를 + * 지나는 마디에는 교차점을 꽂아 격자 계단을 막는다. + * + * 날개 몫 행(구조물 점유 구간 밖)을 **주황 스케치의 바깥선**에서 끝내는 데 쓴다 + * (2026-08-27 사용자: "주황색 바깥선으로 잘라야 해"). 안쪽 경계로는 자르지 않는다 — + * 자르면 구조물 구간과 날개 사이가 벌어진다. 벽 끝점 횡단선으로 자르던 것도 폐기 + * (스케치보다 바깥이라 스케치에 없는 자리까지 판이 남았다). + */ +export function clipRunToRange( + run: OffsetPoint[], + from: number, + to: number, + sign: number, +): OffsetPoint[] { + const inside = (point: OffsetPoint): boolean => + (point.offset_m - from) * sign >= -1e-9 && (to - point.offset_m) * sign >= -1e-9; + const between = (a: OffsetPoint, b: OffsetPoint, offset: number): OffsetPoint => { + const step = b.offset_m - a.offset_m; + const ratio = Math.abs(step) < 1e-12 ? 0 : (offset - a.offset_m) / step; + return { + offset_m: offset, + elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * ratio, + }; + }; + const kept: OffsetPoint[] = []; + for (let i = 0; i < run.length; i += 1) { + const point = run[i]; + if (i > 0) { + const previous = run[i - 1]; + const step = point.offset_m - previous.offset_m; + const crossings = [from, to] + .filter((bound) => (previous.offset_m - bound) * (point.offset_m - bound) < 0) + .sort((a, b) => (step >= 0 ? a - b : b - a)); + for (const bound of crossings) kept.push(between(previous, point, bound)); + } + if (inside(point)) kept.push(point); + } + return kept; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index e0d9dd13..4195e269 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -129,6 +129,8 @@ interface CellClip { function clipCellByChord( areas: ReadonlyArray, ring: ReadonlyArray, + /** 남길 쪽 — 기본은 커브 **밖**(기본 성토면). 패치는 **안**을 남긴다. */ + keep: "outside" | "inside" = "outside", ): CellClip | null { const hits = ringCrossings(areas, ring); // 교점은 보통 2개다. 귀퉁이를 스치거나 커브 마디가 셀 안에서 꺾이면 더 나오는데, @@ -154,6 +156,7 @@ function clipCellByChord( } } if (!keepSign) return null; + if (keep === "inside") keepSign = -keepSign; // 볼록 사각형을 직선으로 자른다 — 결과도 볼록이라 부채꼴 삼각분할이 항상 옳다. const kept: Corner[] = []; for (let i = 0; i < ring.length; i += 1) { @@ -405,7 +408,10 @@ export function maskFillByPlanCurves( } const cut = ribbons.map((ribbon) => { const areas = areasBySide.get(ribbon.side); - if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon; + // 패치는 `patchClip`이 붙은 것만 다룬다 — **절취 영역 안쪽만 남기는 반대 규칙**이다 + // (2026-08-27 사용자). 표식 없는 패치(BOX암거·기슭막이)는 종전대로 손대지 않는다. + const patchClip = ribbon.patch === true && ribbon.patchClip === true; + if (ribbon.kind !== "fill" || (ribbon.patch && !patchClip) || !areas?.length) return ribbon; const rows = ribbon.chainages.length; const cols = ribbon.colCount; if (rows < 2 || cols < 2) return ribbon; @@ -435,6 +441,33 @@ export function maskFillByPlanCurves( const inCount = ring.filter((corner) => areas.some((area) => inside(area, corner.p[0], corner.p[1])), ).length; + if (patchClip) { + // 반대 규칙 — 영역 **안**이 남고 **밖**이 지워진다. + if (inCount === 4) continue; + if (inCount === 0) { + mask[row * (cols - 1) + col] = 1; + masked += 1; + continue; + } + const piece = clipCellByChord(areas, ring, "inside"); + if (!piece) { + if (coverage(areas, ring) < 0.5) { + mask[row * (cols - 1) + col] = 1; + masked += 1; + } + continue; + } + mask[row * (cols - 1) + col] = 1; + masked += 1; + for (let k = 1; k < piece.kept.length - 1; k += 1) { + for (const corner of [piece.kept[0], piece.kept[k], piece.kept[k + 1]]) { + tris.push(corner.p[0], corner.p[1], corner.p[2]); + const [u, v] = uvOf(row, corner); + uvs.push(u, v); + } + } + continue; + } if (inCount === 0) continue; if (inCount === 4) { mask[row * (cols - 1) + col] = 1; @@ -469,6 +502,15 @@ export function maskFillByPlanCurves( // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; const planes = planesBySide.get(ribbon.side) ?? []; + if (patchClip) { + return { + ...ribbon, + cellMask: mask, + ...(tris.length + ? { trimTris: new Float64Array(tris), trimUvs: new Float32Array(uvs) } + : {}), + }; + } traceMaskLoops(rows, cols, mask).forEach((raw) => { const loop = dropCollinear(raw); if (loop.length < 4 || !planes.length) return; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index a72c98c3..0929a29a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -60,6 +60,13 @@ export interface StructureSilhouette { * 한 줄 리본으로는 스스로 접힌다. */ fillRuns?: OffsetPoint[][]; + /** + * **세월교 바닥판 상단 표고** — 패치를 날개 스케치 안쪽으로 늘릴 때의 **천장**이다 + * (2026-08-27 사용자: "바닥 상단에 교차되지 않는 지점으로 메시가 튀어 나간다 — + * 바닥 상단 Z로 트림"). 성토 물매로 안쪽을 역연장하면 표고가 올라가 유로 위로 + * 솟는다. 세월교만 싣는다(다른 구조물은 안쪽 확장 대상이 아니다). + */ + floorTopZ?: number; } /** 실루엣에서 노견 확장점 찾기 — 첫 점(노견) 표고 그대로 바깥으로 나간 가장 먼 점. */ @@ -203,6 +210,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { widenTo: widenToOf(points, onLeft ? "left" : "right"), patchPoints, fillRuns, + floorTopZ: side.slabTopElevation, }, ]; }); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index d113174a..59f89033 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -194,6 +194,16 @@ export function createRouteViewer(): RouteViewer { return hit ? sceneToModel(hit.point, bounds).z : null; } + // 검증용 — 화면에 **진짜 구멍**이 났는지는 리본 좌표만으로는 못 가린다(구조물 + // 솔리드가 덮고 있을 수 있다). 씬·THREE·모델→씬 변환을 내보내 위에서 레이캐스트로 + // 확인한다(`__corridorBuild`·`__corridorClip`과 같은 용도). + (window as unknown as { __corridorScene?: unknown }).__corridorScene = { + scene, + THREE, + toScene: (x: number, y: number, z: number) => + bounds ? modelToScene({ x, y, z }, bounds) : null, + topZ: () => (bounds ? bounds.z[1] + 100 : null), + }; const markers = createRouteMarkers(scene, () => bounds, terrainElevation); // 회전·줌 중심을 커서 아래 지형 지점으로 (B04 뷰어들과 공용 유틸). // 마커를 잡고 있는 동안에는 회전을 넘겨 드래그 이동이 우선하게 한다.