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:
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<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 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<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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<CorridorKind, number> = {
|
||||
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<Record<CorridorKind, 1 | -1>> = { cut: 1, fill: -1 };
|
||||
|
||||
/** 지형과의 z-fight 방지용 폴리곤 오프셋 — 코리도가 항상 살짝 앞에 그려진다. */
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,23 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// [초기선 복원] 시 그래프의 배관 투영도 지운다(관 정본은 배수유역 초기화가 맡는다).
|
||||
() => 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<void> {
|
||||
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↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||
|
||||
@@ -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<typeof section.design>),
|
||||
inlet_structure: section.design.inlet_structure,
|
||||
basin_adjust: section.design.basin_adjust,
|
||||
};
|
||||
}
|
||||
draw();
|
||||
callbacks?.onCrossDesignsUpdated?.();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user