From 7fdbb343ab8dca62fa3f51083bac0a47f702182e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 13:11:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EC=BD=94=EB=A6=AC=EB=8F=84=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?+=20=ED=94=84=EB=A6=AC=EC=A6=88=20=EC=A0=9C=EA=B1=B0=20+=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=ED=92=88=EC=A7=88=204=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 편집 반영 - Profile_Panel 프리뷰를 full_designs로 올려 설계선까지 갱신, 갱신 후 onCrossDesignsUpdated 콜백 → Page가 코리도 재빌드(500ms 디바운스) - 종단 계획고를 올리면 3D 예상형상이 그 자리에서 따라 바뀐다 프리즈 제거 (프레임 갭 실측 3603ms → 100ms, 100ms 초과 0회) - Corridor_Terrain(신규): 지형 XZ 균일격자 높이 색인. 심 보정 raycast가 BVH 없이 전 삼각형을 훑어 3.6초 단일 블록을 만들던 것을 버킷 조회로 대체. 지형 로드당 1회만 구축 - Corridor_Clip: 스트립 셀·경계선분 균일격자 색인, 핫루프를 정점 복사에서 인덱스 재구성으로 전환(원본 버퍼 재사용, 잘린 조각만 덧붙임) 구분 품질 (사용자 확정 4종) - 색 재설계: 지형 고도색(주황~녹)과 겹치던 적갈/초록을 자주(절토)·청록(성토) 으로, 도로계는 무채색, 측구 하늘색 - 종류 경계 윤곽선, 절·성토 빗금(해칭) 텍스처 방향 구분(절 우상향/성 좌상향) - 시·종점 마구리 봉인 — 설계선↔지반선 절단면 스트립(저장 왕복 포함) Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Corridor.ts | 4 + B05_Profile/B05_Profile_UI_Corridor_Build.ts | 34 +++- B05_Profile/B05_Profile_UI_Corridor_Clip.ts | 174 ++++++++++++---- B05_Profile/B05_Profile_UI_Corridor_Mesh.ts | 187 ++++++++++++++++-- .../B05_Profile_UI_Corridor_Terrain.ts | 132 +++++++++++++ B05_Profile/B05_Profile_UI_Page.ts | 19 ++ B05_Profile/B05_Profile_UI_Profile_Panel.ts | 26 ++- B05_Profile/B05_Profile_UI_Viewer.ts | 31 ++- 8 files changed, 545 insertions(+), 62 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Terrain.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 54f56408..3a875372 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -39,6 +39,8 @@ interface CorridorEnvelope { positionsBase64: string; }>; outline: CorridorBuildResult["outline"]; + /** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */ + caps?: CorridorBuildResult["caps"]; } interface CacheEntry { @@ -122,6 +124,7 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope { positionsBase64: base64FromFloat32(ribbon.positions), })), outline: build.outline, + caps: build.caps, }; } @@ -135,6 +138,7 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult { positions: float32FromBase64(ribbon.positionsBase64), })), 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 2dcbdef0..73812fee 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -28,10 +28,18 @@ export interface CorridorRibbon { positions: Float32Array; } +/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */ +export interface CorridorCap { + /** [x, y, z설계, z지반] × N — offset 오름차순. */ + points: Array<[number, number, number, number]>; +} + export interface CorridorBuildResult { ribbons: CorridorRibbon[]; /** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */ outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> }; + /** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */ + caps: CorridorCap[]; } /** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */ @@ -67,6 +75,8 @@ interface StationPieces { roadEdge: { left: OffsetPoint; right: OffsetPoint }; /** 최외곽(catch) offset/z — 클리핑 외곽선. */ outer: { left: OffsetPoint; right: OffsetPoint }; + /** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */ + capSection: Array<[number, number, number]>; } function pieceKey(kind: CorridorKind, side: CorridorSide): string { @@ -255,6 +265,15 @@ function classifyStation(section: CrossSection): StationPieces | null { left: { offset_m: endL, elevation_m: zOf(endL) }, right: { offset_m: endR, elevation_m: zOf(endR) }, }, + // 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다. + capSection: slicePolyline(sorted, endR, endL).map( + (point) => + [ + point.offset_m, + point.elevation_m, + ground ? ground(point.offset_m) : point.elevation_m, + ] as [number, number, number], + ), }; } @@ -417,5 +436,18 @@ export function buildCorridor( outline.left.push([lx, ly]); outline.right.push([rx, ry]); }); - return { ribbons, outline }; + + // 시·종점 마구리 — 코리도가 끊기는 자리를 설계선↔지반선으로 봉인해 속이 안 보이게 한다. + const caps: CorridorCap[] = [rows[0], rows[rows.length - 1]] + .map((row, index) => { + const station = index === 0 ? stations[0] : stations[stations.length - 1]; + return { + points: station.capSection.map(([offset, zDesign, zGround]) => { + const [x, y] = toModel(row, { offset_m: offset, elevation_m: zDesign }); + return [x, y, zDesign, zGround] as [number, number, number, number]; + }), + }; + }) + .filter((cap) => cap.points.length >= 2); + return { ribbons, outline, caps }; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts index 49d5a8e0..25c2a306 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Clip.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Clip.ts @@ -76,6 +76,13 @@ export class CorridorStrip { readonly maxX: number; readonly minZ: number; readonly maxZ: number; + /** 균일격자 공간색인 — 셀·선분을 통째로 훑으면 지형 삼각형 수 × 셀 수가 되어 + * 편집 반복 시 초 단위로 멎는다(2026-08-23 프리즈 보고). 격자로 후보만 본다. */ + private grid = 1; + private gridCols = 1; + private gridRows = 1; + private cellBuckets: number[][] = []; + private segmentBuckets: number[][] = []; constructor(build: CorridorBuildResult, bounds: ModelBounds) { const cx = (bounds.x[0] + bounds.x[1]) / 2; @@ -133,10 +140,56 @@ export class CorridorStrip { this.maxX = maxX; this.minZ = minZ; this.maxZ = maxZ; + this.buildIndex(); + } + + /** 셀·선분을 균일격자 버킷에 넣는다(격자 한 칸 ≈ 코리도 폭). */ + private buildIndex(): void { + if (!this.cells.length) return; + const meanCell = + this.cells.reduce((sum, cell) => sum + (cell.maxX - cell.minX) + (cell.maxZ - cell.minZ), 0) / + this.cells.length; + this.grid = Math.max(2, meanCell); + this.gridCols = Math.max(1, Math.ceil((this.maxX - this.minX) / this.grid) + 1); + this.gridRows = Math.max(1, Math.ceil((this.maxZ - this.minZ) / this.grid) + 1); + this.cellBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); + this.segmentBuckets = Array.from({ length: this.gridCols * this.gridRows }, () => []); + const put = ( + buckets: number[][], + index: number, + minX: number, + maxX: number, + minZ: number, + maxZ: number, + ): void => { + const c0 = this.colOf(minX); + const c1 = this.colOf(maxX); + const r0 = this.rowOf(minZ); + const r1 = this.rowOf(maxZ); + for (let r = r0; r <= r1; r += 1) { + for (let c = c0; c <= c1; c += 1) buckets[r * this.gridCols + c].push(index); + } + }; + this.cells.forEach((cell, i) => + put(this.cellBuckets, i, cell.minX, cell.maxX, cell.minZ, cell.maxZ), + ); + this.segments.forEach((s, i) => put(this.segmentBuckets, i, s.minX, s.maxX, s.minZ, s.maxZ)); + } + + private colOf(x: number): number { + return Math.min(this.gridCols - 1, Math.max(0, Math.floor((x - this.minX) / this.grid))); + } + + private rowOf(z: number): number { + return Math.min(this.gridRows - 1, Math.max(0, Math.floor((z - this.minZ) / this.grid))); } contains(p: P2): boolean { - for (const cell of this.cells) { + if (p.x < this.minX || p.x > this.maxX || p.z < this.minZ || p.z > this.maxZ) return false; + const bucket = this.cellBuckets[this.rowOf(p.z) * this.gridCols + this.colOf(p.x)]; + if (!bucket) return false; + for (const index of bucket) { + const cell = this.cells[index]; if (p.x < cell.minX || p.x > cell.maxX || p.z < cell.minZ || p.z > cell.maxZ) continue; if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) { return true; @@ -145,11 +198,26 @@ export class CorridorStrip { return false; } - /** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보. */ + /** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보(격자 후보만 검사). */ segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number) { - return this.segments.filter( - (s) => s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ, - ); + if (!this.segmentBuckets.length) return []; + const seen = new Set(); + const result: (typeof this.segments)[number][] = []; + const c0 = this.colOf(minX); + const c1 = this.colOf(maxX); + const r0 = this.rowOf(minZ); + const r1 = this.rowOf(maxZ); + for (let r = r0; r <= r1; r += 1) { + for (let c = c0; c <= c1; c += 1) { + for (const index of this.segmentBuckets[r * this.gridCols + c]) { + if (seen.has(index)) continue; + seen.add(index); + const s = this.segments[index]; + if (s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ) result.push(s); + } + } + } + return result; } } @@ -230,17 +298,50 @@ export function clipGeometry( const index = geometry.getIndex(); const triCount = index ? index.count / 3 : position.count / 3; - const outPositions: number[] = []; - const outColors: number[] = []; + // 성능(2026-08-23 프리즈 개선): 원본 정점 버퍼는 그대로 두고 **인덱스만** 새로 만든다. + // 코리도는 지형 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를 + // 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만 + // 새 정점으로 뒤에 덧붙인다. + const px = position.array as ArrayLike; + const cx = color ? (color.array as ArrayLike) : null; + const keptIndices: number[] = []; + const extraPositions: number[] = []; + const extraColors: number[] = []; + const baseCount = position.count; let changed = false; - const vertexAt = (i: number): number[] => [position.getX(i), position.getY(i), position.getZ(i)]; - const colorAt = (i: number): number[] | null => - color ? [color.getX(i), color.getY(i), color.getZ(i)] : null; - const emit = (tri: Tri): void => { + const readTri = (ia: number, ib: number, ic: number): Tri => ({ + px: Float64Array.from([ + px[ia * 3], + px[ia * 3 + 1], + px[ia * 3 + 2], + px[ib * 3], + px[ib * 3 + 1], + px[ib * 3 + 2], + px[ic * 3], + px[ic * 3 + 1], + 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], + ]) + : null, + }); + /** 잘린 조각 — 새 정점으로 덧붙이고 그 인덱스를 쓴다. */ + const emitFragment = (tri: Tri): void => { for (let v = 0; v < 3; v += 1) { - outPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]); - if (tri.color) outColors.push(tri.color[v * 3], tri.color[v * 3 + 1], tri.color[v * 3 + 2]); + 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]); } }; @@ -248,34 +349,34 @@ export function clipGeometry( const ia = index ? index.getX(t * 3) : t * 3; const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1; const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2; - const pa = vertexAt(ia); - const pb = vertexAt(ib); - const pc = vertexAt(ic); - const minX = Math.min(pa[0], pb[0], pc[0]); - const maxX = Math.max(pa[0], pb[0], pc[0]); - const minZ = Math.min(pa[2], pb[2], pc[2]); - const maxZ = Math.max(pa[2], pb[2], pc[2]); - const tri: Tri = { - px: Float64Array.from([...pa, ...pb, ...pc]), - color: color ? Float64Array.from([...colorAt(ia)!, ...colorAt(ib)!, ...colorAt(ic)!]) : null, - }; - // 코리도 전체 AABB 밖 — 그대로 유지. + const ax = px[ia * 3]; + const az = px[ia * 3 + 2]; + const bx = px[ib * 3]; + const bz = px[ib * 3 + 2]; + const ccx = px[ic * 3]; + const ccz = px[ic * 3 + 2]; + const minX = Math.min(ax, bx, ccx); + const maxX = Math.max(ax, bx, ccx); + const minZ = Math.min(az, bz, ccz); + const maxZ = Math.max(az, bz, ccz); + // 코리도 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만). if (maxX < strip.minX || minX > strip.maxX || maxZ < strip.minZ || minZ > strip.maxZ) { - emit(tri); + keptIndices.push(ia, ib, ic); continue; } const near = strip.segmentsNear(minX, maxX, minZ, maxZ); if (near.length === 0) { // 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는 // 삼각형은 안/밖 어느 한쪽에 통째로 있다). - if (strip.contains(triCentroid(tri))) { + if (strip.contains({ x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 })) { changed = true; continue; } - emit(tri); + keptIndices.push(ia, ib, ic); continue; } // 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정. + const tri = readTri(ia, ib, ic); let fragments: Tri[] = [tri]; for (const segment of near) { const next: Tri[] = []; @@ -290,19 +391,26 @@ export function clipGeometry( } const kept = fragments.filter((fragment) => !strip.contains(triCentroid(fragment))); if (kept.length === fragments.length) { - emit(tri); // 절단은 됐지만 전부 밖 — 원본 그대로 유지(조각 수 증가 방지). + keptIndices.push(ia, ib, ic); // 절단은 됐지만 전부 밖 — 원본 인덱스 유지. } else { changed = true; - kept.forEach(emit); + kept.forEach(emitFragment); } } if (!changed) return null; const clipped = new THREE.BufferGeometry(); - clipped.setAttribute("position", new THREE.Float32BufferAttribute(outPositions, 3)); - if (color && outColors.length) { - clipped.setAttribute("color", new THREE.Float32BufferAttribute(outColors, 3)); + const positions = new Float32Array(baseCount * 3 + extraPositions.length); + 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); + colors.set(cx, 0); + colors.set(extraColors, baseCount * 3); + clipped.setAttribute("color", new THREE.BufferAttribute(colors, 3)); } + clipped.setIndex(keptIndices); clipped.computeVertexNormals(); return clipped; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index e31f3922..f88f4893 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -6,42 +6,115 @@ * 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다. * 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로 * 변환해 담는다. + * + * 품질 4종(2026-08-23 사용자 확정): ① 지형 고도색(주황~녹)과 안 겹치는 색 + * 재설계 ② 종류 경계 윤곽선 ③ 절·성토 빗금(해칭) 텍스처 ④ 시·종점 마구리 봉인. * ========================================================================== */ import * as THREE from "three"; import type { ModelBounds } from "./B05_Profile_UI_Markers"; import type { CorridorBuildResult, + CorridorCap, CorridorKind, CorridorRibbon, } from "./B05_Profile_UI_Corridor_Build"; -/** 종류별 표시 색 — 도면 관례(절토 적갈·성토 녹색), 검증 후 사용자 조정 여지. */ +/** + * 종류별 색 — 지형은 주황~황~녹 고도 램프라 그 대역을 피한다. + * 도로계는 무채색(회), 측구는 청, 절토는 자주(마젠타 계열), 성토는 청록. + * 절·성토를 적갈/초록으로 두면 지형색에 묻혀 구분이 안 됐다(2026-08-23 보고). + */ const KIND_COLORS: Record = { - carriageway: 0x8b8f98, - shoulder: 0xb8bcc4, - ditch: 0x3b82f6, - cut: 0xc2703d, - fill: 0x4f9d4f, + carriageway: 0x3f4451, + shoulder: 0x9aa2b1, + ditch: 0x38bdf8, + cut: 0xb5179e, + fill: 0x06b6d4, }; +/** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */ +const CAP_COLOR = 0x2a2f3a; + +/** 종류 경계 윤곽선 색·굵기 — 도면처럼 종류 경계를 또렷하게. */ +const OUTLINE_COLOR = 0x0f172a; + +/** 빗금(해칭) 방향 — 절토는 우상향, 성토는 좌상향으로 방향까지 구분한다. */ +const HATCH_DIRECTION: Partial> = { cut: 1, fill: -1 }; + /** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */ const POLYGON_OFFSET_FACTOR = -1; -function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.BufferGeometry { +/** 빗금 텍스처 캐시 — 리본마다 캔버스를 새로 그리면 편집 반복 시 낭비다. */ +const hatchCache = new Map(); + +/** 대각 빗금 캔버스 텍스처(반복). direction=1 우상향, -1 좌상향. */ +function hatchTexture(direction: 1 | -1): THREE.Texture { + const cached = hatchCache.get(direction); + if (cached) return cached; + const size = 32; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext("2d"); + if (context) { + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, size, size); + context.strokeStyle = "rgba(0,0,0,0.42)"; + context.lineWidth = 3; + // 타일 경계에서 끊기지 않도록 3줄(−size, 0, +size)을 겹쳐 긋는다. + for (const shift of [-size, 0, size]) { + context.beginPath(); + if (direction > 0) { + context.moveTo(shift, size); + context.lineTo(shift + size, 0); + } else { + context.moveTo(shift, 0); + context.lineTo(shift + size, size); + } + context.stroke(); + } + } + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + hatchCache.set(direction, texture); + return texture; +} + +interface SceneOrigin { + cx: number; + cy: number; + cz: number; +} + +function originOf(bounds: ModelBounds): SceneOrigin { + return { + cx: (bounds.x[0] + bounds.x[1]) / 2, + cy: (bounds.y[0] + bounds.y[1]) / 2, + cz: (bounds.z[0] + bounds.z[1]) / 2, + }; +} + +function ribbonGeometry(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry { const rowCount = ribbon.chainages.length; const cols = ribbon.colCount; - const cx = (bounds.x[0] + bounds.x[1]) / 2; - const cy = (bounds.y[0] + bounds.y[1]) / 2; - const cz = (bounds.z[0] + bounds.z[1]) / 2; const positions = new Float32Array(rowCount * cols * 3); - for (let i = 0; i < rowCount * cols; i += 1) { - const x = ribbon.positions[i * 3]; - const y = ribbon.positions[i * 3 + 1]; - const z = ribbon.positions[i * 3 + 2]; - positions[i * 3] = x - cx; - positions[i * 3 + 1] = z - cz; - positions[i * 3 + 2] = -(y - cy); + // UV — u는 종방향 누가거리(m), v는 횡방향 열 비율. 빗금이 실척으로 반복된다. + const uvs = new Float32Array(rowCount * cols * 2); + const startChainage = ribbon.chainages[0]; + for (let row = 0; row < rowCount; row += 1) { + for (let col = 0; col < cols; col += 1) { + const i = row * cols + col; + const x = ribbon.positions[i * 3]; + const y = ribbon.positions[i * 3 + 1]; + const z = ribbon.positions[i * 3 + 2]; + positions[i * 3] = x - origin.cx; + positions[i * 3 + 1] = z - origin.cz; + positions[i * 3 + 2] = -(y - origin.cy); + uvs[i * 2] = (ribbon.chainages[row] - startChainage) / 2; // 2m마다 1타일. + uvs[i * 2 + 1] = col / Math.max(1, cols - 1); + } } const indices: number[] = []; for (let row = 0; row < rowCount - 1; row += 1) { @@ -55,6 +128,61 @@ function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.Buff } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2)); + geometry.setIndex(indices); + geometry.computeVertexNormals(); + return geometry; +} + +/** 리본 양 가장자리(종류 경계) 선 — 도면처럼 경계를 또렷하게 한다. */ +function ribbonOutline(ribbon: CorridorRibbon, origin: SceneOrigin): THREE.BufferGeometry | null { + const rowCount = ribbon.chainages.length; + const cols = ribbon.colCount; + if (rowCount < 2) return null; + const points: number[] = []; + const push = (row: number, col: number): void => { + const i = (row * cols + col) * 3; + points.push( + ribbon.positions[i] - origin.cx, + // 지형·서피스 위로 살짝 띄워 z-fight로 점선처럼 깜빡이는 것을 막는다. + ribbon.positions[i + 2] - origin.cz + 0.02, + -(ribbon.positions[i + 1] - origin.cy), + ); + }; + for (const col of [0, cols - 1]) { + for (let row = 0; row < rowCount - 1; row += 1) { + push(row, col); + push(row + 1, col); + } + } + return new THREE.BufferGeometry().setAttribute( + "position", + new THREE.Float32BufferAttribute(points, 3), + ); +} + +/** 마구리(시·종점 절단면) — 설계선과 지반선 사이를 세로로 잇는 띠. */ +function capGeometry(cap: CorridorCap, origin: SceneOrigin): THREE.BufferGeometry | null { + if (cap.points.length < 2) return null; + const count = cap.points.length; + const positions = new Float32Array(count * 2 * 3); + cap.points.forEach(([x, y, zDesign, zGround], index) => { + const sceneX = x - origin.cx; + const sceneZ = -(y - origin.cy); + positions[index * 6] = sceneX; + positions[index * 6 + 1] = zDesign - origin.cz; + positions[index * 6 + 2] = sceneZ; + positions[index * 6 + 3] = sceneX; + positions[index * 6 + 4] = zGround - origin.cz; + positions[index * 6 + 5] = sceneZ; + }); + const indices: number[] = []; + for (let i = 0; i < count - 1; i += 1) { + const a = i * 2; + indices.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); return geometry; @@ -64,18 +192,41 @@ function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.Buff export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group { const group = new THREE.Group(); group.name = "corridor"; + const origin = originOf(bounds); build.ribbons.forEach((ribbon) => { if (ribbon.chainages.length < 2) return; + const direction = HATCH_DIRECTION[ribbon.kind]; const material = new THREE.MeshLambertMaterial({ color: KIND_COLORS[ribbon.kind], + // 절·성토만 빗금 — 도면 관례대로 방향으로도 구분된다. + map: direction ? hatchTexture(direction) : null, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: POLYGON_OFFSET_FACTOR, polygonOffsetUnits: POLYGON_OFFSET_FACTOR, }); - const mesh = new THREE.Mesh(ribbonGeometry(ribbon, bounds), material); + const mesh = new THREE.Mesh(ribbonGeometry(ribbon, origin), material); mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`; group.add(mesh); + const outline = ribbonOutline(ribbon, origin); + if (outline) { + const line = new THREE.LineSegments( + outline, + new THREE.LineBasicMaterial({ color: OUTLINE_COLOR, transparent: true, opacity: 0.8 }), + ); + line.name = `corridor-outline:${ribbon.kind}:${ribbon.side}`; + group.add(line); + } + }); + build.caps.forEach((cap, index) => { + const geometry = capGeometry(cap, origin); + if (!geometry) return; + const mesh = new THREE.Mesh( + geometry, + new THREE.MeshLambertMaterial({ color: CAP_COLOR, side: THREE.DoubleSide }), + ); + mesh.name = `corridor-cap:${index}`; + group.add(mesh); }); return group; } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Terrain.ts b/B05_Profile/B05_Profile_UI_Corridor_Terrain.ts new file mode 100644 index 00000000..6a4da49a --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Terrain.ts @@ -0,0 +1,132 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Terrain.ts + * 지형 높이 조회 색인 — 코리도 심 보정(catch 정점 z 투영)용. + * + * 왜(2026-08-23 프리즈 진단): Three.js Raycaster는 BVH 없이 지형 삼각형을 전부 + * 훑는다. 심 보정은 수백 점을 물어보므로 (점 수 × 삼각형 수)가 되어 계획선을 + * 편집할 때마다 3.6초짜리 단일 블록이 생겼다(실측). 지형은 편집 중 바뀌지 않으니 + * 한 번만 XZ 균일격자로 색인해 두고, 조회는 버킷 안 삼각형만 본다. + * ========================================================================== */ + +import * as THREE from "three"; + +/** 격자 한 칸의 목표 삼각형 수 — 너무 잘면 버킷 배열이, 너무 굵으면 조회가 비싸다. */ +const TARGET_TRIS_PER_CELL = 8; + +export class TerrainHeightIndex { + /** 삼각형 정점 (x, y, z) × 3 — 씬 좌표(Y-up). */ + private tris: Float32Array; + private buckets: Int32Array[] = []; + private cols = 1; + private rows = 1; + private cell = 1; + private minX = 0; + private minZ = 0; + readonly triangleCount: number; + + constructor(terrain: THREE.Object3D) { + const collected: number[] = []; + terrain.updateMatrixWorld(true); + const vector = new THREE.Vector3(); + terrain.traverse((child) => { + if (!(child instanceof THREE.Mesh)) return; + const position = child.geometry.getAttribute("position") as THREE.BufferAttribute | undefined; + if (!position) return; + const index = child.geometry.getIndex(); + const count = index ? index.count : position.count; + for (let i = 0; i < count; i += 1) { + const vertex = index ? index.getX(i) : i; + vector + .set(position.getX(vertex), position.getY(vertex), position.getZ(vertex)) + .applyMatrix4(child.matrixWorld); + collected.push(vector.x, vector.y, vector.z); + } + }); + this.tris = Float32Array.from(collected); + this.triangleCount = Math.floor(this.tris.length / 9); + if (this.triangleCount === 0) return; + + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + for (let i = 0; i < this.tris.length; i += 3) { + const x = this.tris[i]; + const z = this.tris[i + 2]; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + const area = Math.max(1, (maxX - minX) * (maxZ - minZ)); + this.cell = Math.max(0.5, Math.sqrt((area * TARGET_TRIS_PER_CELL) / this.triangleCount)); + this.cols = Math.max(1, Math.ceil((maxX - minX) / this.cell) + 1); + this.rows = Math.max(1, Math.ceil((maxZ - minZ) / this.cell) + 1); + this.minX = minX; + this.minZ = minZ; + + // 2단계 채우기 — 개수를 먼저 세고 정확한 크기의 Int32Array를 할당한다 + // (배열의 배열 push보다 메모리·시간이 훨씬 싸다). + const counts = new Int32Array(this.cols * this.rows); + const visit = (callback: (bucket: number, triangle: number) => void): void => { + for (let t = 0; t < this.triangleCount; t += 1) { + const base = t * 9; + const x0 = this.tris[base]; + const z0 = this.tris[base + 2]; + const x1 = this.tris[base + 3]; + const z1 = this.tris[base + 5]; + const x2 = this.tris[base + 6]; + const z2 = this.tris[base + 8]; + const c0 = this.colOf(Math.min(x0, x1, x2)); + const c1 = this.colOf(Math.max(x0, x1, x2)); + const r0 = this.rowOf(Math.min(z0, z1, z2)); + const r1 = this.rowOf(Math.max(z0, z1, z2)); + for (let r = r0; r <= r1; r += 1) { + for (let c = c0; c <= c1; c += 1) callback(r * this.cols + c, t); + } + } + }; + visit((bucket) => (counts[bucket] += 1)); + this.buckets = new Array(this.cols * this.rows); + for (let i = 0; i < counts.length; i += 1) this.buckets[i] = new Int32Array(counts[i]); + const cursor = new Int32Array(counts.length); + visit((bucket, triangle) => { + this.buckets[bucket][cursor[bucket]] = triangle; + cursor[bucket] += 1; + }); + } + + private colOf(x: number): number { + return Math.min(this.cols - 1, Math.max(0, Math.floor((x - this.minX) / this.cell))); + } + + private rowOf(z: number): number { + return Math.min(this.rows - 1, Math.max(0, Math.floor((z - this.minZ) / this.cell))); + } + + /** 씬 좌표 (x, z) 자리의 지형 높이(y). 지형 밖이면 null. 가장 높은 면을 택한다. */ + heightAt(x: number, z: number): number | null { + if (this.triangleCount === 0) return null; + const bucket = this.buckets[this.rowOf(z) * this.cols + this.colOf(x)]; + if (!bucket || bucket.length === 0) return null; + let best: number | null = null; + for (let i = 0; i < bucket.length; i += 1) { + const base = bucket[i] * 9; + const x0 = this.tris[base]; + const z0 = this.tris[base + 2]; + const x1 = this.tris[base + 3]; + const z1 = this.tris[base + 5]; + const x2 = this.tris[base + 6]; + const z2 = this.tris[base + 8]; + const denominator = (z1 - z2) * (x0 - x2) + (x2 - x1) * (z0 - z2); + if (Math.abs(denominator) < 1e-12) continue; + const w0 = ((z1 - z2) * (x - x2) + (x2 - x1) * (z - z2)) / denominator; + const w1 = ((z2 - z0) * (x - x2) + (x0 - x2) * (z - z2)) / denominator; + const w2 = 1 - w0 - w1; + if (w0 < -1e-6 || w1 < -1e-6 || w2 < -1e-6) continue; + const y = w0 * this.tris[base + 1] + w1 * this.tris[base + 4] + w2 * this.tris[base + 7]; + if (best === null || y > best) best = y; + } + return best; + } +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 35a60872..4acecfce 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -92,6 +92,23 @@ export async function renderB05Route(root: HTMLElement): Promise { // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). () => bridge.clearProjectedStations(), { + // 계획선 편집 프리뷰가 횡단 설계선을 갱신하면 3D 코리도도 재빌드(2026-08-23). + // 연타·드래그 중 프리뷰 응답마다 전체 파이프라인(빌드+스냅+클립)을 돌리면 + // 프리즈가 오므로, 편집이 잦아든 뒤 1회만 돌린다(2026-08-23 사용자 보고). + onCrossDesignsUpdated: () => { + window.clearTimeout(corridorRefreshTimer); + corridorRefreshTimer = window.setTimeout(() => { + if (currentSectionDetail && latest?.route?.id && (latest.route_points?.length ?? 0) > 1) { + refreshCorridor( + viewer, + activeProjectId, + latest.route.id, + currentSectionDetail, + latest.route_points, + ); + } + }, 500); + }, // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. // 정본은 배수유역도의 관 지점이며, 화면 목록은 그것을 실체화한 것이다. onPipesChanged: (pipes) => { @@ -161,6 +178,8 @@ export async function renderB05Route(root: HTMLElement): Promise { let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; + /** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */ + let corridorRefreshTimer = 0; let routeReady = false; let restoring = true; // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 3939425c..27f933b0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -121,6 +121,9 @@ export interface RouteProfilePanelCallbacks { onBasinSelected?: (chainageM: number | null) => void; /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ onPipeSelected?: (chainageM: number | null) => void; + /** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 — + * 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */ + onCrossDesignsUpdated?: () => void; } export function createRouteProfilePanel( @@ -396,22 +399,31 @@ export function createRouteProfilePanel( if (!detail || routeId === null) return; const seq = (crossPreviewSeq += 1); const targetRouteId = routeId; - void previewCrossDesigns(projectId, targetRouteId, store.edits()) + // full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한 + // 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값 없으면 + // DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다. + void previewCrossDesigns(projectId, targetRouteId, store.edits(), undefined, { + fullDesigns: true, + }) .then((next) => { if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return; // 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로 - // 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 응답에는 바뀌는 값(설계·계획선)만 - // 오므로 지반선 샘플은 건드리지 않는다. + // 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다. const designByChainage = new Map( next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), ); for (const section of detail.cross_sections) { - const patch = designByChainage.get(section.chainage_m.toFixed(3)); - // 부분 갱신 — 온 필드만 덮어쓴다. 설계선 좌표 등 안 온 값은 B06이 상세를 - // 받을 때 정확한 값으로 채워지므로 여기서 지우지 않는다. - if (patch && section.design) section.design = { ...section.design, ...patch }; + const full = designByChainage.get(section.chainage_m.toFixed(3)); + if (!full || !section.design) continue; + // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존. + section.design = { + ...(full as NonNullable), + inlet_structure: section.design.inlet_structure, + basin_adjust: section.design.basin_adjust, + }; } draw(); + callbacks?.onCrossDesignsUpdated?.(); }) .catch(() => { /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 95728e30..fef4f94f 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -17,6 +17,7 @@ import { import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh"; import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; +import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; @@ -126,6 +127,8 @@ export function createRouteViewer(): RouteViewer { let corridorBuild: CorridorBuildResult | null = null; let corridorGroup: THREE.Group | null = null; let clippedTerrain: THREE.Object3D | null = null; + /** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */ + let heightIndex: TerrainHeightIndex | null = null; let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). const contours = new THREE.Group(); @@ -425,8 +428,26 @@ export function createRouteViewer(): RouteViewer { corridorGroup = null; } - /** 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. */ + /** 지형 높이 색인 확보 — 지형 1회당 한 번만 만든다(빌드 비용 O(삼각형)). */ + function ensureHeightIndex(): TerrainHeightIndex | null { + if (heightIndex) return heightIndex; + if (!terrain) return null; + heightIndex = new TerrainHeightIndex(terrain); + return heightIndex.triangleCount > 0 ? heightIndex : null; + } + + /** + * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. + * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 + * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). + */ function snapCorridorEdges(build: CorridorBuildResult): void { + if (!bounds) return; + const index = ensureHeightIndex(); + if (!index) return; + const ox = (bounds.x[0] + bounds.x[1]) / 2; + const oy = (bounds.y[0] + bounds.y[1]) / 2; + const oz = (bounds.z[0] + bounds.z[1]) / 2; build.ribbons.forEach((ribbon) => { if (ribbon.kind !== "cut" && ribbon.kind !== "fill" && ribbon.kind !== "ditch") return; const cols = ribbon.colCount; @@ -441,8 +462,11 @@ export function createRouteViewer(): RouteViewer { ribbon.positions[outer + 1] - ribbon.positions[inner + 1], ); if (width < 1e-4) continue; - const ground = terrainElevation(ribbon.positions[outer], ribbon.positions[outer + 1]); - if (ground !== null) ribbon.positions[outer + 2] = ground; + const height = index.heightAt( + ribbon.positions[outer] - ox, + -(ribbon.positions[outer + 1] - oy), + ); + if (height !== null) ribbon.positions[outer + 2] = height + oz; } }); } @@ -489,6 +513,7 @@ export function createRouteViewer(): RouteViewer { scene.remove(terrain); disposeObject(terrain); } + heightIndex = null; // 지형이 바뀌면 높이 색인도 새로 만든다. const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). const buffer = await fetchCachedBytes(projectId, url);