feat(B05): 코리도 편집 즉시 반영 + 프리즈 제거 + 구분 품질 4종

편집 반영
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:11:49 +09:00
co-authored by Claude Opus 5
parent 3b997963e3
commit 7fdbb343ab
8 changed files with 545 additions and 62 deletions
+4
View File
@@ -39,6 +39,8 @@ interface CorridorEnvelope {
positionsBase64: string; positionsBase64: string;
}>; }>;
outline: CorridorBuildResult["outline"]; outline: CorridorBuildResult["outline"];
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
caps?: CorridorBuildResult["caps"];
} }
interface CacheEntry { interface CacheEntry {
@@ -122,6 +124,7 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
positionsBase64: base64FromFloat32(ribbon.positions), positionsBase64: base64FromFloat32(ribbon.positions),
})), })),
outline: build.outline, outline: build.outline,
caps: build.caps,
}; };
} }
@@ -135,6 +138,7 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
positions: float32FromBase64(ribbon.positionsBase64), positions: float32FromBase64(ribbon.positionsBase64),
})), })),
outline: envelope.outline, outline: envelope.outline,
caps: envelope.caps ?? [],
}; };
} }
+33 -1
View File
@@ -28,10 +28,18 @@ export interface CorridorRibbon {
positions: Float32Array; positions: Float32Array;
} }
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
export interface CorridorCap {
/** [x, y, z설계, z지반] × N — offset 오름차순. */
points: Array<[number, number, number, number]>;
}
export interface CorridorBuildResult { export interface CorridorBuildResult {
ribbons: CorridorRibbon[]; ribbons: CorridorRibbon[];
/** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */ /** 클리핑 경계 — 프레임별 좌/우 최외곽(catch) XY(모델). 좌우 같은 길이. */
outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> }; outline: { chainages: number[]; left: Array<[number, number]>; right: Array<[number, number]> };
/** 노선 시·종점 단면 봉인(2026-08-23 품질 개선). 캡이 없으면 빈 배열. */
caps: CorridorCap[];
} }
/** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */ /** 조각별 고정 열 수 — 보간 시 t-리샘플 기준. 비탈은 무릎(2단 경사)까지 담게 넉넉히. */
@@ -67,6 +75,8 @@ interface StationPieces {
roadEdge: { left: OffsetPoint; right: OffsetPoint }; roadEdge: { left: OffsetPoint; right: OffsetPoint };
/** 최외곽(catch) offset/z — 클리핑 외곽선. */ /** 최외곽(catch) offset/z — 클리핑 외곽선. */
outer: { left: OffsetPoint; right: OffsetPoint }; outer: { left: OffsetPoint; right: OffsetPoint };
/** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */
capSection: Array<[number, number, number]>;
} }
function pieceKey(kind: CorridorKind, side: CorridorSide): string { 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) }, left: { offset_m: endL, elevation_m: zOf(endL) },
right: { offset_m: endR, elevation_m: zOf(endR) }, 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.left.push([lx, ly]);
outline.right.push([rx, ry]); 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 };
} }
+141 -33
View File
@@ -76,6 +76,13 @@ export class CorridorStrip {
readonly maxX: number; readonly maxX: number;
readonly minZ: number; readonly minZ: number;
readonly maxZ: 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) { constructor(build: CorridorBuildResult, bounds: ModelBounds) {
const cx = (bounds.x[0] + bounds.x[1]) / 2; const cx = (bounds.x[0] + bounds.x[1]) / 2;
@@ -133,10 +140,56 @@ export class CorridorStrip {
this.maxX = maxX; this.maxX = maxX;
this.minZ = minZ; this.minZ = minZ;
this.maxZ = maxZ; 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 { 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 (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)) { if (pointInTri2(p, cell.a, cell.b, cell.c) || pointInTri2(p, cell.a, cell.c, cell.d)) {
return true; return true;
@@ -145,11 +198,26 @@ export class CorridorStrip {
return false; return false;
} }
/** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보. */ /** 삼각형 AABB와 겹치는 경계 선분들 — 정밀 절단 후보(격자 후보만 검사). */
segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number) { segmentsNear(minX: number, maxX: number, minZ: number, maxZ: number) {
return this.segments.filter( if (!this.segmentBuckets.length) return [];
(s) => s.maxX >= minX && s.minX <= maxX && s.maxZ >= minZ && s.minZ <= maxZ, const seen = new Set<number>();
); 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 index = geometry.getIndex();
const triCount = index ? index.count / 3 : position.count / 3; const triCount = index ? index.count / 3 : position.count / 3;
const outPositions: number[] = []; // 성능(2026-08-23 프리즈 개선): 원본 정점 버퍼는 그대로 두고 **인덱스만** 새로 만든다.
const outColors: number[] = []; // 코리도는 지형 전체에서 가느다란 띠라 삼각형 대부분이 그대로 남는다 — 그 다수를
// 정점 복사 없이 정수 3개 push로 넘기면 편집 반복에도 멎지 않는다. 잘린 조각만
// 새 정점으로 뒤에 덧붙인다.
const px = position.array as ArrayLike<number>;
const cx = color ? (color.array as ArrayLike<number>) : null;
const keptIndices: number[] = [];
const extraPositions: number[] = [];
const extraColors: number[] = [];
const baseCount = position.count;
let changed = false; let changed = false;
const vertexAt = (i: number): number[] => [position.getX(i), position.getY(i), position.getZ(i)]; const readTri = (ia: number, ib: number, ic: number): Tri => ({
const colorAt = (i: number): number[] | null => px: Float64Array.from([
color ? [color.getX(i), color.getY(i), color.getZ(i)] : null; px[ia * 3],
const emit = (tri: Tri): void => { 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) { for (let v = 0; v < 3; v += 1) {
outPositions.push(tri.px[v * 3], tri.px[v * 3 + 1], tri.px[v * 3 + 2]); keptIndices.push(baseCount + extraPositions.length / 3);
if (tri.color) outColors.push(tri.color[v * 3], tri.color[v * 3 + 1], tri.color[v * 3 + 2]); 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 ia = index ? index.getX(t * 3) : t * 3;
const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1; const ib = index ? index.getX(t * 3 + 1) : t * 3 + 1;
const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2; const ic = index ? index.getX(t * 3 + 2) : t * 3 + 2;
const pa = vertexAt(ia); const ax = px[ia * 3];
const pb = vertexAt(ib); const az = px[ia * 3 + 2];
const pc = vertexAt(ic); const bx = px[ib * 3];
const minX = Math.min(pa[0], pb[0], pc[0]); const bz = px[ib * 3 + 2];
const maxX = Math.max(pa[0], pb[0], pc[0]); const ccx = px[ic * 3];
const minZ = Math.min(pa[2], pb[2], pc[2]); const ccz = px[ic * 3 + 2];
const maxZ = Math.max(pa[2], pb[2], pc[2]); const minX = Math.min(ax, bx, ccx);
const tri: Tri = { const maxX = Math.max(ax, bx, ccx);
px: Float64Array.from([...pa, ...pb, ...pc]), const minZ = Math.min(az, bz, ccz);
color: color ? Float64Array.from([...colorAt(ia)!, ...colorAt(ib)!, ...colorAt(ic)!]) : null, const maxZ = Math.max(az, bz, ccz);
}; // 코리도 전체 AABB 밖 — 그대로 유지(정점 복사 없이 인덱스만).
// 코리도 전체 AABB 밖 — 그대로 유지.
if (maxX < strip.minX || minX > strip.maxX || maxZ < strip.minZ || minZ > strip.maxZ) { if (maxX < strip.minX || minX > strip.maxX || maxZ < strip.minZ || minZ > strip.maxZ) {
emit(tri); keptIndices.push(ia, ib, ic);
continue; continue;
} }
const near = strip.segmentsNear(minX, maxX, minZ, maxZ); const near = strip.segmentsNear(minX, maxX, minZ, maxZ);
if (near.length === 0) { if (near.length === 0) {
// 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는 // 경계와 무관 — 전부 안이면 버리고 아니면 유지(경계 선분이 안 닿는
// 삼각형은 안/밖 어느 한쪽에 통째로 있다). // 삼각형은 안/밖 어느 한쪽에 통째로 있다).
if (strip.contains(triCentroid(tri))) { if (strip.contains({ x: (ax + bx + ccx) / 3, z: (az + bz + ccz) / 3 })) {
changed = true; changed = true;
continue; continue;
} }
emit(tri); keptIndices.push(ia, ib, ic);
continue; continue;
} }
// 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정. // 경계 근접 — 실교차 세그먼트로 순차 절단 후 무게중심 판정.
const tri = readTri(ia, ib, ic);
let fragments: Tri[] = [tri]; let fragments: Tri[] = [tri];
for (const segment of near) { for (const segment of near) {
const next: Tri[] = []; const next: Tri[] = [];
@@ -290,19 +391,26 @@ export function clipGeometry(
} }
const kept = fragments.filter((fragment) => !strip.contains(triCentroid(fragment))); const kept = fragments.filter((fragment) => !strip.contains(triCentroid(fragment)));
if (kept.length === fragments.length) { if (kept.length === fragments.length) {
emit(tri); // 절단은 됐지만 전부 밖 — 원본 그대로 유지(조각 수 증가 방지). keptIndices.push(ia, ib, ic); // 절단은 됐지만 전부 밖 — 원본 인덱스 유지.
} else { } else {
changed = true; changed = true;
kept.forEach(emit); kept.forEach(emitFragment);
} }
} }
if (!changed) return null; if (!changed) return null;
const clipped = new THREE.BufferGeometry(); const clipped = new THREE.BufferGeometry();
clipped.setAttribute("position", new THREE.Float32BufferAttribute(outPositions, 3)); const positions = new Float32Array(baseCount * 3 + extraPositions.length);
if (color && outColors.length) { positions.set(px, 0);
clipped.setAttribute("color", new THREE.Float32BufferAttribute(outColors, 3)); 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(); clipped.computeVertexNormals();
return clipped; return clipped;
} }
+169 -18
View File
@@ -6,42 +6,115 @@
* 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다. * 아니라 차도/노견/측구/절토비탈/성토비탈 리본을 개별 Mesh로 만든다.
* 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로 * 정점은 뷰어 공통 규약(modelToScene)으로 씬 좌표(Y-up, bounds 중심 원점)로
* 변환해 담는다. * 변환해 담는다.
*
* 품질 4종(2026-08-23 사용자 확정): ① 지형 고도색(주황~녹)과 안 겹치는 색
* 재설계 ② 종류 경계 윤곽선 ③ 절·성토 빗금(해칭) 텍스처 ④ 시·종점 마구리 봉인.
* ========================================================================== */ * ========================================================================== */
import * as THREE from "three"; import * as THREE from "three";
import type { ModelBounds } from "./B05_Profile_UI_Markers"; import type { ModelBounds } from "./B05_Profile_UI_Markers";
import type { import type {
CorridorBuildResult, CorridorBuildResult,
CorridorCap,
CorridorKind, CorridorKind,
CorridorRibbon, CorridorRibbon,
} from "./B05_Profile_UI_Corridor_Build"; } from "./B05_Profile_UI_Corridor_Build";
/** 종류별 표시 색 — 도면 관례(절토 적갈·성토 녹색), 검증 후 사용자 조정 여지. */ /**
* 종류별 색 — 지형은 주황~황~녹 고도 램프라 그 대역을 피한다.
* 도로계는 무채색(회), 측구는 청, 절토는 자주(마젠타 계열), 성토는 청록.
* 절·성토를 적갈/초록으로 두면 지형색에 묻혀 구분이 안 됐다(2026-08-23 보고).
*/
const KIND_COLORS: Record<CorridorKind, number> = { const KIND_COLORS: Record<CorridorKind, number> = {
carriageway: 0x8b8f98, carriageway: 0x3f4451,
shoulder: 0xb8bcc4, shoulder: 0x9aa2b1,
ditch: 0x3b82f6, ditch: 0x38bdf8,
cut: 0xc2703d, cut: 0xb5179e,
fill: 0x4f9d4f, fill: 0x06b6d4,
}; };
/** 마구리(시·종점 절단면) 색 — 절단면임이 드러나게 어둡게. */
const CAP_COLOR = 0x2a2f3a;
/** 종류 경계 윤곽선 색·굵기 — 도면처럼 종류 경계를 또렷하게. */
const OUTLINE_COLOR = 0x0f172a;
/** 빗금(해칭) 방향 — 절토는 우상향, 성토는 좌상향으로 방향까지 구분한다. */
const HATCH_DIRECTION: Partial<Record<CorridorKind, 1 | -1>> = { cut: 1, fill: -1 };
/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */ /** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */
const POLYGON_OFFSET_FACTOR = -1; const POLYGON_OFFSET_FACTOR = -1;
function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.BufferGeometry { /** 빗금 텍스처 캐시 — 리본마다 캔버스를 새로 그리면 편집 반복 시 낭비다. */
const hatchCache = new Map<number, THREE.Texture>();
/** 대각 빗금 캔버스 텍스처(반복). 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 rowCount = ribbon.chainages.length;
const cols = ribbon.colCount; 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); const positions = new Float32Array(rowCount * cols * 3);
for (let i = 0; i < rowCount * cols; i += 1) { // UV — u는 종방향 누가거리(m), v는 횡방향 열 비율. 빗금이 실척으로 반복된다.
const x = ribbon.positions[i * 3]; const uvs = new Float32Array(rowCount * cols * 2);
const y = ribbon.positions[i * 3 + 1]; const startChainage = ribbon.chainages[0];
const z = ribbon.positions[i * 3 + 2]; for (let row = 0; row < rowCount; row += 1) {
positions[i * 3] = x - cx; for (let col = 0; col < cols; col += 1) {
positions[i * 3 + 1] = z - cz; const i = row * cols + col;
positions[i * 3 + 2] = -(y - cy); 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[] = []; const indices: number[] = [];
for (let row = 0; row < rowCount - 1; row += 1) { 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(); const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); 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.setIndex(indices);
geometry.computeVertexNormals(); geometry.computeVertexNormals();
return geometry; return geometry;
@@ -64,18 +192,41 @@ function ribbonGeometry(ribbon: CorridorRibbon, bounds: ModelBounds): THREE.Buff
export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group { export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBounds): THREE.Group {
const group = new THREE.Group(); const group = new THREE.Group();
group.name = "corridor"; group.name = "corridor";
const origin = originOf(bounds);
build.ribbons.forEach((ribbon) => { build.ribbons.forEach((ribbon) => {
if (ribbon.chainages.length < 2) return; if (ribbon.chainages.length < 2) return;
const direction = HATCH_DIRECTION[ribbon.kind];
const material = new THREE.MeshLambertMaterial({ const material = new THREE.MeshLambertMaterial({
color: KIND_COLORS[ribbon.kind], color: KIND_COLORS[ribbon.kind],
// 절·성토만 빗금 — 도면 관례대로 방향으로도 구분된다.
map: direction ? hatchTexture(direction) : null,
side: THREE.DoubleSide, side: THREE.DoubleSide,
polygonOffset: true, polygonOffset: true,
polygonOffsetFactor: POLYGON_OFFSET_FACTOR, polygonOffsetFactor: POLYGON_OFFSET_FACTOR,
polygonOffsetUnits: 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}`; mesh.name = `corridor:${ribbon.kind}:${ribbon.side}`;
group.add(mesh); 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; return group;
} }
@@ -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;
}
}
+19
View File
@@ -92,6 +92,23 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다). // [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
() => bridge.clearProjectedStations(), () => 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) => { onPipesChanged: (pipes) => {
@@ -161,6 +178,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let latest: RouteLatestResponse | null = null; let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS; let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null; let currentSectionDetail: SectionDetailResponse | null = null;
/** 계획선 편집 중 코리도 재빌드 디바운스 — 연타 프리즈 방지(2026-08-23). */
let corridorRefreshTimer = 0;
let routeReady = false; let routeReady = false;
let restoring = true; let restoring = true;
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단). // 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
+19 -7
View File
@@ -121,6 +121,9 @@ export interface RouteProfilePanelCallbacks {
onBasinSelected?: (chainageM: number | null) => void; onBasinSelected?: (chainageM: number | null) => void;
/** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */ /** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */
onPipeSelected?: (chainageM: number | null) => void; onPipeSelected?: (chainageM: number | null) => void;
/** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 —
* 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */
onCrossDesignsUpdated?: () => void;
} }
export function createRouteProfilePanel( export function createRouteProfilePanel(
@@ -396,22 +399,31 @@ export function createRouteProfilePanel(
if (!detail || routeId === null) return; if (!detail || routeId === null) return;
const seq = (crossPreviewSeq += 1); const seq = (crossPreviewSeq += 1);
const targetRouteId = routeId; 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) => { .then((next) => {
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return; if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로 // 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 응답에는 바뀌는 값(설계·계획선)만 // 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다.
// 오므로 지반선 샘플은 건드리지 않는다.
const designByChainage = new Map( const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]), next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
); );
for (const section of detail.cross_sections) { for (const section of detail.cross_sections) {
const patch = designByChainage.get(section.chainage_m.toFixed(3)); const full = designByChainage.get(section.chainage_m.toFixed(3));
// 부분 갱신 — 온 필드만 덮어쓴다. 설계선 좌표 등 안 온 값은 B06이 상세를 if (!full || !section.design) continue;
// 받을 때 정확한 값으로 채워지므로 여기서 지우지 않는다. // 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
if (patch && section.design) section.design = { ...section.design, ...patch }; section.design = {
...(full as NonNullable<typeof section.design>),
inlet_structure: section.design.inlet_structure,
basin_adjust: section.design.basin_adjust,
};
} }
draw(); draw();
callbacks?.onCrossDesignsUpdated?.();
}) })
.catch(() => { .catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */ /* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
+28 -3
View File
@@ -17,6 +17,7 @@ import {
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh"; import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
import { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38; const DARK_VIEWER_BACKGROUND = 0x251f38;
@@ -126,6 +127,8 @@ export function createRouteViewer(): RouteViewer {
let corridorBuild: CorridorBuildResult | null = null; let corridorBuild: CorridorBuildResult | null = null;
let corridorGroup: THREE.Group | null = null; let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null; let clippedTerrain: THREE.Object3D | null = null;
/** 지형 높이 격자 색인 — 지형을 새로 불러올 때만 다시 만든다(편집 중 재사용). */
let heightIndex: TerrainHeightIndex | null = null;
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
const contours = new THREE.Group(); const contours = new THREE.Group();
@@ -425,8 +428,26 @@ export function createRouteViewer(): RouteViewer {
corridorGroup = null; 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 { 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) => { build.ribbons.forEach((ribbon) => {
if (ribbon.kind !== "cut" && ribbon.kind !== "fill" && ribbon.kind !== "ditch") return; if (ribbon.kind !== "cut" && ribbon.kind !== "fill" && ribbon.kind !== "ditch") return;
const cols = ribbon.colCount; const cols = ribbon.colCount;
@@ -441,8 +462,11 @@ export function createRouteViewer(): RouteViewer {
ribbon.positions[outer + 1] - ribbon.positions[inner + 1], ribbon.positions[outer + 1] - ribbon.positions[inner + 1],
); );
if (width < 1e-4) continue; if (width < 1e-4) continue;
const ground = terrainElevation(ribbon.positions[outer], ribbon.positions[outer + 1]); const height = index.heightAt(
if (ground !== null) ribbon.positions[outer + 2] = ground; 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); scene.remove(terrain);
disposeObject(terrain); disposeObject(terrain);
} }
heightIndex = null; // 지형이 바뀌면 높이 색인도 새로 만든다.
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url); const buffer = await fetchCachedBytes(projectId, url);