From fdfc0fb893b95ae6616f4ad6b5737531c278210e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 13:42:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=BD=94=EB=A6=AC=EB=8F=84=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AC=ED=95=91=20=ED=81=AC=EB=9E=98=EC=8B=9C=C2=B7?= =?UTF-8?q?=EC=A7=80=ED=98=95=20=EB=A0=8C=EB=8D=94=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?+=20=EC=B8=A1=EA=B5=AC=20=EA=B5=AC=EA=B0=84=20=EB=B6=84?= =?UTF-8?q?=ED=95=A0=20+=20=EC=A2=85=EB=8B=A8=20=EC=8A=A4=EB=AC=B4?= =?UTF-8?q?=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클리핑 정상화 (절토가 원지반에 묻혀 안 보이던 원인) - 정점 버퍼를 count 길이로 잘라 복사 — 'offset is out of bounds'로 클리핑 전체가 죽어 원지반이 하나도 안 잘리고 있었다(삼각형 485,358 → 477,239 확인) - 정점색을 원본 배열 타입·normalized 그대로 복사(GLB는 정규화 Uint8 RGBA) - 원본에 법선이 없으면 클리핑본도 만들지 않는다 — 직접 계산하면 감기 방향이 원본과 달라 지형이 새까맣게 렌더됐다 표현 규칙 - 측구가 한쪽 측점에만 있으면 측점 사이 중간에서 끊는다(사용자 확정). 폭 0 축퇴로 잇던 방식을 걷어내 없는 자리의 서피스·윤곽선 제거 - 리본을 조각이 존재하는 연속 구간별로 분리 생성 - 종단 세분 2m → 1m, 측점 사이 표고를 종단 계획선(종단곡선 포함) 기준으로 보정 — 도로·측구는 전량, 비탈은 지반 접점까지 감쇠 - 측점 가로선을 예상형상 표시 중에는 계획고 위로 올림 - 지형 기본 표시를 흑백으로 Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 7 +- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 131 +++++++++++++++---- B05_Profile/B05_Profile_UI_Corridor_Clip.ts | 71 +++++++--- B05_Profile/B05_Profile_UI_Page.ts | 40 +++++- B05_Profile/B05_Profile_UI_Panel.ts | 3 +- B05_Profile/B05_Profile_UI_Viewer.ts | 47 ++++++- 6 files changed, 249 insertions(+), 50 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 3a875372..8b95877d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -213,7 +213,12 @@ export async function ensureCorridor( } } - const build = buildCorridor(detail.cross_sections, routePoints); + // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. + const build = buildCorridor( + detail.cross_sections, + routePoints, + detail.longitudinal.design_profiles?.[0]?.samples, + ); if (!build) { cache.delete(key); return null; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 73812fee..c2ab7511 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -51,8 +51,9 @@ const PIECE_COLS: Record = { fill: 9, }; -/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). */ -const SUBDIVIDE_STEP_M = 2; +/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분). + * 1m로 좁혀 종단 방향 서피스를 더 부드럽게 한다(2026-08-23 사용자 요청). */ +const SUBDIVIDE_STEP_M = 1; interface XY { x: number; @@ -317,6 +318,48 @@ function lerpPoints(a: OffsetPoint[], b: OffsetPoint[], t: number): OffsetPoint[ })); } +/** 종단 계획선 표고 보간기 — chainage로 계획고를 되짚는다(종단곡선 포함). */ +function buildProfileSampler( + samples?: Array<{ chainage_m: number; elevation_m: number }>, +): ((chainage: number) => number | null) | null { + if (!samples || samples.length < 2) return null; + const sorted = [...samples].sort((a, b) => a.chainage_m - b.chainage_m); + return (chainage: number): number | null => { + if (chainage <= sorted[0].chainage_m) return sorted[0].elevation_m; + for (let i = 1; i < sorted.length; i += 1) { + if (chainage <= sorted[i].chainage_m) { + const span = sorted[i].chainage_m - sorted[i - 1].chainage_m; + const t = span <= 1e-12 ? 0 : (chainage - sorted[i - 1].chainage_m) / span; + return sorted[i - 1].elevation_m + (sorted[i].elevation_m - sorted[i - 1].elevation_m) * t; + } + } + return sorted[sorted.length - 1].elevation_m; + }; +} + +/** + * 종단 보정량을 단면에 싣는다. 도로·노견·측구는 계획고에 붙어 있으므로 통째로, + * 절·성토 비탈은 안쪽(도로측) 전량 → 바깥쪽(지반 접점) 0으로 감쇠시킨다 — + * 그래야 비탈 끝이 지반에서 떨어지지 않는다. + */ +function applyProfileShift( + points: OffsetPoint[], + kind: CorridorKind, + side: CorridorSide, + shift: number, +): void { + if (kind !== "cut" && kind !== "fill") { + points.forEach((point) => (point.elevation_m += shift)); + return; + } + const last = points.length - 1; + points.forEach((point, index) => { + // 좌측 비탈은 index 0이 도로측, 우측 비탈은 index last가 도로측(offset 부호 반대). + const innerRatio = side === "right" ? index / last : 1 - index / last; + point.elevation_m += shift * innerRatio; + }); +} + /** 조각이 없는 측점의 대응 폴리라인 — road edge 한 점으로 축퇴(리본 폭 0 수렴). */ function degeneratePiece( station: StationPieces, @@ -334,6 +377,9 @@ function degeneratePiece( export function buildCorridor( crossSections: CrossSection[], routePoints: RoutePoint[], + /** 종단 계획선 샘플 — 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함) + * 따라가게 한다(2026-08-23 사용자: 종단 방향을 더 부드럽게). */ + designSamples?: Array<{ chainage_m: number; elevation_m: number }>, subdivideStepM: number = SUBDIVIDE_STEP_M, ): CorridorBuildResult | null { const stations = crossSections @@ -343,6 +389,8 @@ export function buildCorridor( if (stations.length < 2) return null; const sampler = buildPolylineSampler(routePoints); + // 종단 계획선 보간기 — 없으면 측점 간 직선(구 동작)으로 떨어진다. + const profileZ = buildProfileSampler(designSamples); // 등장하는 (kind,side) 전체 — 리본 목록 확정. const keys = new Set(); stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key))); @@ -376,13 +424,36 @@ export function buildCorridor( y: s0.center.y + (s1.center.y - s0.center.y) * t, }); const left = slerpLeft(s0.left, s1.left, t); + // 종단 보정 — 측점 사이를 직선으로 이으면 종단곡선이 각진다. 계획선 실제 + // 표고와 직선 보간값의 차이만큼 단면을 통째로 올린다(도로·측구는 전부, + // 비탈은 안쪽에서 바깥으로 0까지 감쇠시켜 지반 접점을 지킨다). + const shift = profileZ + ? (profileZ(chainage) ?? 0) - + ((profileZ(s0.chainage_m) ?? 0) + + ((profileZ(s1.chainage_m) ?? 0) - (profileZ(s0.chainage_m) ?? 0)) * t) + : 0; const sections = new Map(); keys.forEach((key) => { const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; - // 한쪽 측점에 없는 조각은 road edge로 축퇴시켜 리본 폭이 0으로 수렴(전이 구간). - const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side); - const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side); - sections.set(key, lerpPoints(pa, pb, t)); + const hasA = s0.pieces.has(key); + const hasB = s1.pieces.has(key); + // 양쪽 다 없으면 그 구간에는 조각 자체가 없다 — 리본을 끊어 없는 자리에 + // 서피스가 생기지 않게 한다(2026-08-23 사용자 지적: 측구 없는 구간). + if (!hasA && !hasB) return; + // 측구는 한쪽에만 있으면 **측점 사이 중간에서 끊는다**(2026-08-23 사용자 확정). + // 폭 0으로 길게 수렴시키면 없는 구간까지 얇은 쐐기가 남는다. 횡단배수 연결 + // 자동 계산이 생기기 전까지의 표현 규칙이다. + let points: OffsetPoint[]; + if (kind === "ditch" && hasA !== hasB) { + if (hasA ? t > 0.5 : t < 0.5) return; + points = (hasA ? s0 : s1).pieces.get(key)!.map((point) => ({ ...point })); + } else { + const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side); + const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side); + points = lerpPoints(pa, pb, t); + } + if (shift !== 0) applyProfileShift(points, kind, side, shift); + sections.set(key, points); }); const lerpOuter = (a: OffsetPoint, b: OffsetPoint): OffsetPoint => ({ offset_m: a.offset_m + (b.offset_m - a.offset_m) * t, @@ -410,22 +481,38 @@ export function buildCorridor( keys.forEach((key) => { const [kind, side] = key.split(":") as [CorridorKind, CorridorSide]; const colCount = PIECE_COLS[kind]; - const chainages: number[] = []; - const positions = new Float32Array(rows.length * colCount * 3); - let cursor = 0; - rows.forEach((row) => { - chainages.push(row.chainage_m); - // sections는 프레임마다 전 키를 채우므로(축퇴 포함) 항상 존재한다. - const points = row.sections.get(key)!; - points.forEach((point) => { - const [x, y, z] = toModel(row, point); - positions[cursor] = x; - positions[cursor + 1] = y; - positions[cursor + 2] = z; - cursor += 3; - }); - }); - ribbons.push({ kind, side, colCount, chainages, positions }); + // 조각이 있는 **연속 구간**마다 리본을 따로 만든다 — 없는 구간을 폭 0으로 + // 이어 붙이면 측구가 없는 자리에도 서피스·윤곽선이 남는다(2026-08-23 수정). + let start = -1; + const flush = (endExclusive: number): void => { + if (start < 0 || endExclusive - start < 2) { + start = -1; + return; + } + const chainages: number[] = []; + const positions = new Float32Array((endExclusive - start) * colCount * 3); + let cursor = 0; + for (let row = start; row < endExclusive; row += 1) { + chainages.push(rows[row].chainage_m); + rows[row].sections.get(key)!.forEach((point) => { + const [x, y, z] = toModel(rows[row], point); + positions[cursor] = x; + positions[cursor + 1] = y; + positions[cursor + 2] = z; + cursor += 3; + }); + } + ribbons.push({ kind, side, colCount, chainages, positions }); + start = -1; + }; + for (let row = 0; row < rows.length; row += 1) { + if (rows[row].sections.has(key)) { + if (start < 0) start = row; + } else { + flush(row); + } + } + flush(rows.length); }); const outline: CorridorBuildResult["outline"] = { chainages: [], left: [], right: [] }; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts index 25c2a306..9d990d69 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -302,8 +302,22 @@ export function clipGeometry( // 코리도는 지형 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를 // 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만 // 새 정점으로 뒤에 덧붙인다. - const px = position.array as ArrayLike; - const cx = color ? (color.array as ArrayLike) : null; + // 버퍼는 count*3보다 길 수 있다(로더가 버퍼를 공유·패딩하는 경우) — 정확히 + // 쓰는 구간만 잘라 쓴다. 통째로 복사하면 대상 배열을 넘어 터진다 + // (2026-08-23 "offset is out of bounds"로 클리핑 전체가 죽어 있던 원인). + const px = (position.array as Float32Array).subarray(0, position.count * 3); + // 정점색은 RGB(3)일 수도 RGBA(4)일 수도 있다 — trimesh GLB는 RGBA로 내보낸다. + // stride를 고정하면 색이 어긋나 지형이 새까맣게 보인다(2026-08-23 화면 검증). + const colorSize = color?.itemSize ?? 3; + // 정점색 배열은 정규화 Uint8일 수 있다(GLB 관례) — **원본 배열 그대로** 복사하고 + // normalized 플래그까지 물려받아야 색이 그대로 산다. Float32로 옮겨 담으면 + // 0~255 raw가 그대로 들어가 색이 날아간다(2026-08-23 화면 검증). + const cx = color + ? (color.array as unknown as { subarray(a: number, b: number): ArrayLike }).subarray( + 0, + color.count * colorSize, + ) + : null; const keptIndices: number[] = []; const extraPositions: number[] = []; const extraColors: number[] = []; @@ -323,17 +337,11 @@ export function clipGeometry( px[ic * 3 + 2], ]), color: cx - ? Float64Array.from([ - cx[ia * 3], - cx[ia * 3 + 1], - cx[ia * 3 + 2], - cx[ib * 3], - cx[ib * 3 + 1], - cx[ib * 3 + 2], - cx[ic * 3], - cx[ic * 3 + 1], - cx[ic * 3 + 2], - ]) + ? Float64Array.from( + [ia, ib, ic].flatMap((v) => + Array.from({ length: colorSize }, (_unused, c) => cx[v * colorSize + c]), + ), + ) : null, }); /** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */ @@ -341,7 +349,9 @@ export function clipGeometry( for (let v = 0; v < 3; v += 1) { keptIndices.push(baseCount + extraPositions.length / 3); extraPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]); - if (cx) extraColors.push(tri.color![v * 3], tri.color![v * 3 + 1], tri.color![v * 3 + 2]); + if (cx) { + for (let c = 0; c < colorSize; c += 1) extraColors.push(tri.color![v * colorSize + c]); + } } }; @@ -404,14 +414,37 @@ export function clipGeometry( positions.set(px, 0); positions.set(extraPositions, baseCount * 3); clipped.setAttribute("position", new THREE.BufferAttribute(positions, 3)); - if (cx) { - const colors = new Float32Array(baseCount * 3 + extraColors.length); + if (color && cx) { + const source = color.array as unknown as { + constructor: new (length: number) => { set(a: ArrayLike, o?: number): void }; + }; + const colors = new source.constructor(baseCount * colorSize + extraColors.length); colors.set(cx, 0); - colors.set(extraColors, baseCount * 3); - clipped.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + // 정수 배열이면 보간색 소수부가 잘린다 — 반올림해 담는다. + colors.set( + color.normalized ? extraColors.map((value) => Math.round(value)) : extraColors, + baseCount * colorSize, + ); + clipped.setAttribute( + "color", + new THREE.BufferAttribute(colors as unknown as THREE.TypedArray, colorSize, color.normalized), + ); } clipped.setIndex(keptIndices); - clipped.computeVertexNormals(); + // 법선은 원본이 가진 것만 물려받는다. 원본에 법선이 없으면(GLB가 안 실어 보내는 + // 경우) **우리도 만들지 않는다** — 직접 계산하면 감기 방향이 원본과 달라져 지형이 + // 새까맣게 보였다(2026-08-23 화면 검증). 없을 때의 음영은 THREE가 알아서 만든다. + const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; + if (normal && normal.count >= baseCount) { + const normals = new Float32Array(baseCount * 3 + extraPositions.length); + normals.set((normal.array as Float32Array).subarray(0, baseCount * 3), 0); + for (let i = 0; i < extraPositions.length; i += 3) { + normals[baseCount * 3 + i] = 0; + normals[baseCount * 3 + i + 1] = 1; + normals[baseCount * 3 + i + 2] = 0; + } + clipped.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + } return clipped; } diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 4acecfce..39e58888 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -180,6 +180,8 @@ export async function renderB05Route(root: HTMLElement): Promise { let currentSectionDetail: SectionDetailResponse | null = null; /** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */ let corridorRefreshTimer = 0; + /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ + let corridorVisible = true; let routeReady = false; let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). @@ -261,7 +263,12 @@ export async function renderB05Route(root: HTMLElement): Promise { onReset: () => void resetDesign(), onContourApply: (interval) => void applyContours(interval), onSurfaceVisible: viewer.setSurfaceVisible, - onCorridorVisible: viewer.setCorridorVisible, + onCorridorVisible: (visible) => { + corridorVisible = visible; + viewer.setCorridorVisible(visible); + // 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다. + if (currentSectionDetail) renderStationLines(currentSectionDetail); + }, onContoursVisible: viewer.setContoursVisible, onAxesVisible: viewer.setAxesVisible, onStationLinesVisible: viewer.setStationLinesVisible, @@ -389,12 +396,33 @@ export async function renderB05Route(root: HTMLElement): Promise { // 중심 좌표는 노선 폴리라인 기준 — 3D 마커가 노선 위에 정확히 얹힌다(2026-08-19). latest?.route_points ?? [], ); + // 예상형상이 켜져 있으면 측점 바를 **계획고** 기준으로 올린다 — 원지반 위에 + // 얹힌 예상형상에 묻혀 측점 기준선이 안 보이던 문제(2026-08-23 사용자 지시). + const designAt = (chainageM: number): number | null => { + if (!corridorVisible) return null; + const samples = detail.longitudinal.design_profiles?.[0]?.samples; + if (!samples?.length) return null; + let best = samples[0]; + for (const sample of samples) { + if (Math.abs(sample.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM)) { + best = sample; + } + } + return best.elevation_m; + }; // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. - const withUphill = [...regular, ...injected].map((station) => ({ - ...station, - uphill_side: - uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, - })); + const withUphill = [...regular, ...injected].map((station) => { + const design = designAt(station.chainage_m); + return { + ...station, + center_z: + design !== null && station.center_z !== null + ? Math.max(station.center_z, design) + : station.center_z, + uphill_side: + uphillOverrides.get(uphillKey(station.chainage_m)) ?? station.uphill_side ?? null, + }; + }); viewer.renderStationLines(withUphill, roadWidths[panel.values().gradeClass] / 2); } diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index baa986db..80dff800 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -223,7 +223,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) { toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible), // 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청). - toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale), + // 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다. + toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale), ); const separator1 = document.createElement("span"); separator1.className = "b05-route__view-separator"; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index fef4f94f..7f1bfb4e 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -22,6 +22,43 @@ import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; +/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ +function countTriangles(root: THREE.Object3D | null): number { + let total = 0; + root?.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const index = child.geometry.getIndex(); + const position = child.geometry.getAttribute("position"); + total += Math.floor((index ? index.count : (position?.count ?? 0)) / 3); + }); + return total; +} + +/** 서피스 진단 요약 — 색·법선·재질이 원본과 어긋났는지 화면 밖에서 확인한다. */ +function describeSurface(root: THREE.Object3D | null): unknown { + const out: unknown[] = []; + root?.traverse((child) => { + if (!(child instanceof THREE.Mesh) || out.length >= 2) return; + const geometry = child.geometry; + const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; + const normal = geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; + const material = ( + Array.isArray(child.material) ? child.material[0] : child.material + ) as THREE.MeshLambertMaterial; + out.push({ + type: material?.type, + vertexColors: material?.vertexColors, + matColor: material?.color?.getHexString(), + side: material?.side, + colorSize: color?.itemSize, + color0: color ? [color.getX(0), color.getY(0), color.getZ(0)] : null, + normal0: normal ? [normal.getX(0), normal.getY(0), normal.getZ(0)] : null, + hasNormal: Boolean(normal), + }); + }); + return out; +} + function disposeObject(object: THREE.Object3D | null): void { object?.traverse((child) => { if ( @@ -379,7 +416,8 @@ export function createRouteViewer(): RouteViewer { // 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라 // (원본은 userData에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다. - let surfaceGrayscale = false; + // 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다. + let surfaceGrayscale = true; function applySurfaceGrayscale(): void { [terrain, clippedTerrain].forEach((target) => applyGrayscaleTo(target)); } @@ -486,6 +524,13 @@ export function createRouteViewer(): RouteViewer { disposeClippedTerrain(); clippedTerrain = clipped; scene.add(clipped); + // 검증용 요약 — 원지반이 실제로 잘렸는지 화면 밖에서 수치로 확인한다. + (window as unknown as { __corridorClip?: unknown }).__corridorClip = { + source: countTriangles(terrain), + clipped: countTriangles(clipped), + detail: describeSurface(clipped), + sourceDetail: describeSurface(terrain), + }; applyGrayscaleTo(clipped); // 흑백 토글 상태 유지. applyCorridorVisibility(); });