feat(B05): 3D 새 ① 브레이크라인 국소 재삼각분할 · 구조물 둘레 드레이프 · 밴드 좁힘 · 3D 방식 고르기 한 줄(115-5 P1 · P2)
- 노면 끝 · 비탈 끝 · 단면 꺾임 · 비탈 마구리 · 구조물 윗면 외곽 · 관 입구 선을 지형에 끼워 걸친 삼각형만 다시 나눔(변 교점 꼭짓점 번호 순 식 · 일직선 마디 되살림) · 색은 면 단위 - 구조물 구멍은 윗면 외곽만 · 풋프린트 안 · 둘레 0.75 m 띠는 솔리드 윗면 아래로 · 관 입구만 구멍 - 손대는 자리 = 행별 비탈 끝 + 5 m 띠(밖 지형 그대로) - 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① · 제안 ②」 — 방식 등록부(B05_Profile_UI_Corridor_Modes.ts) · 설정 키 corridor-mode 하나(옛 두 키 읽어서 옮김) · 기본 새 ① · 제안 ② 는 빈자리(단추 꺼짐) - 226 실측: T자 균열(1 cm 넘음) 298 → 0 · 구조물 둘레 0.1 m 넘음 57 % → 12 % · 색 번짐 p90 3.1 → 0 m · 발끝 어긋남 p90 2.0 → 0.15 m Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -495,11 +495,12 @@ export function buildDeformField(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 그 자리의 비탈 종류 — 앞뒤 행 중 하나라도 비탈 끝 안이면 그 종류. */
|
||||
function zoneOf(r: number, si: number, s: number): 0 | 1 | 2 {
|
||||
for (const k of [r, r + 1]) {
|
||||
if (s < catches[k * 4 + si * 2]) return 1;
|
||||
if (s < catches[k * 4 + si * 2 + 1]) return 2;
|
||||
/** 그 자리의 비탈 종류 — 앞뒤 행 비탈 끝을 보간한 선 안이면 그 종류(비탈 끝 브레이크라인과 같은 선). */
|
||||
function zoneOf(r: number, si: number, s: number, t: number): 0 | 1 | 2 {
|
||||
for (let ki = 0; ki < 2; ki += 1) {
|
||||
const a = catches[r * 4 + si * 2 + ki];
|
||||
const b = catches[(r + 1) * 4 + si * 2 + ki];
|
||||
if (s < a * (1 - t) + b * t) return ki === 0 ? 1 : 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -515,7 +516,7 @@ export function buildDeformField(
|
||||
out = Math.min(out, Math.max(z, limit.ceiling ?? z));
|
||||
}
|
||||
}
|
||||
return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s) };
|
||||
return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s, at.t) };
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Deform_Breakline.ts
|
||||
* 새 방식 지형의 **브레이크라인 국소 재삼각분할**(계획서 115-5 P1-3).
|
||||
*
|
||||
* 노면 끝(앵커) · 비탈 끝 · 단면 꺾임 · 구조물 구멍 선을 지형 메시에 끼워 넣는다.
|
||||
* · 선이 걸친 삼각형만 그 삼각형 안 선 조각으로 면을 나눠(평면 그래프 면 걷기)
|
||||
* `ShapeUtils.triangulateShape`(earcut)로 다시 삼각분할한다.
|
||||
* · 삼각형 변과 선의 교점은 **변 꼭짓점 번호 순서로 정한 식**으로 내 이웃 삼각형과
|
||||
* 비트까지 같다 — 변형 뒤에도 T자 균열이 없다(`_Clip.ts` 무한직선 절단 대체).
|
||||
* · 지울 자리(노면부 · 구멍) 면은 버리고, 남은 면은 무게중심 자리로 한 색(성토 · 절토 ·
|
||||
* 원지반)을 칠한다 — 색 경계 = 비탈 끝 선.
|
||||
* · 손댈 자리(`touched` — 행별 비탈 끝 + 5 m 띠) 밖 삼각형은 번호만 넘긴다(P2-5).
|
||||
*
|
||||
* 원본 geometry 는 읽기만 한다 — 새 위치 · 속성 배열을 만든다. 법선은 만들지 않는다(`_Clip.ts`).
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { P2 } from "./B05_Profile_UI_Corridor_Region";
|
||||
|
||||
/** 브레이크라인 하나 — 씬 평면(x, z) 꺾은선. 닫힌 고리는 끝점 = 첫 점. */
|
||||
export type Breakline = P2[];
|
||||
|
||||
/** 변형 결과(씬 Y) · 자리(0 원지반 · 1 성토 · 2 절토). */
|
||||
export interface BandPoint {
|
||||
y: number;
|
||||
zone: 0 | 1 | 2;
|
||||
}
|
||||
|
||||
export interface BandContext {
|
||||
lines: ReadonlyArray<Breakline>;
|
||||
/** 지울 자리(노면부 · 구조물 구멍). */
|
||||
removed: (p: P2) => boolean;
|
||||
/** 손댈 자리 — 밖 삼각형은 그대로 넘긴다. */
|
||||
touched: (p: P2) => boolean;
|
||||
/** 씬 좌표 정점 → 변형 높이 · 자리. */
|
||||
deform: (x: number, y: number, z: number) => BandPoint;
|
||||
/** 자리별 색(r, g, b · 0~1). */
|
||||
tint: (zone: 1 | 2) => [number, number, number];
|
||||
}
|
||||
|
||||
export interface BandStats {
|
||||
split: number;
|
||||
/** 선이 걸쳤는데 못 나눈 삼각형(퇴화) — 통째로 둔다. */
|
||||
fallback: number;
|
||||
moved: number;
|
||||
raised: number;
|
||||
lowered: number;
|
||||
maxRaiseM: number;
|
||||
maxCutM: number;
|
||||
}
|
||||
|
||||
const CELL_M = 2;
|
||||
/** 점 같음 판정(m) — 교점 · 꼭짓점 물림. */
|
||||
const SNAP_M = 1e-6;
|
||||
/** 이보다 작게 움직이면 움직였다고 세지 않는다(m). */
|
||||
const MOVE_MIN_M = 0.01;
|
||||
|
||||
/** 선분 격자 색인 — 선분 [px, pz, qx, qz] 와 칸별 선분 번호. */
|
||||
class SegmentGrid {
|
||||
readonly segs: number[] = [];
|
||||
private cells = new Map<number, number[]>();
|
||||
private minX = Infinity;
|
||||
private minZ = Infinity;
|
||||
private cols = 1;
|
||||
|
||||
constructor(lines: ReadonlyArray<Breakline>) {
|
||||
for (const line of lines) {
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const p = line[i - 1];
|
||||
const q = line[i];
|
||||
if (Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M) continue;
|
||||
this.segs.push(p.x, p.z, q.x, q.z);
|
||||
this.minX = Math.min(this.minX, p.x, q.x);
|
||||
this.minZ = Math.min(this.minZ, p.z, q.z);
|
||||
}
|
||||
}
|
||||
let maxX = -Infinity;
|
||||
for (let s = 0; s < this.segs.length; s += 4)
|
||||
maxX = Math.max(maxX, this.segs[s], this.segs[s + 2]);
|
||||
this.cols = Math.max(1, Math.ceil((maxX - this.minX) / CELL_M) + 1);
|
||||
for (let s = 0; s < this.segs.length; s += 4) {
|
||||
const [c0, c1] = this.span(this.segs[s], this.segs[s + 2], this.minX);
|
||||
const [r0, r1] = this.span(this.segs[s + 1], this.segs[s + 3], this.minZ);
|
||||
for (let r = r0; r <= r1; r += 1) {
|
||||
for (let c = c0; c <= c1; c += 1) {
|
||||
const key = r * this.cols + c;
|
||||
const list = this.cells.get(key);
|
||||
if (list) list.push(s / 4);
|
||||
else this.cells.set(key, [s / 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private span(a: number, b: number, origin: number): [number, number] {
|
||||
return [
|
||||
Math.floor((Math.min(a, b) - origin) / CELL_M),
|
||||
Math.floor((Math.max(a, b) - origin) / CELL_M),
|
||||
];
|
||||
}
|
||||
|
||||
/** 상자에 걸칠 수 있는 선분 번호(중복 없음). */
|
||||
near(minX: number, maxX: number, minZ: number, maxZ: number): number[] {
|
||||
if (!this.segs.length) return [];
|
||||
const [c0, c1] = this.span(minX, maxX, this.minX);
|
||||
const [r0, r1] = this.span(minZ, maxZ, this.minZ);
|
||||
const out: number[] = [];
|
||||
for (let r = r0; r <= r1; r += 1) {
|
||||
for (let c = c0; c <= c1; c += 1) {
|
||||
const list = this.cells.get(r * this.cols + c);
|
||||
if (list) for (const s of list) if (!out.includes(s)) out.push(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
const cross = (ax: number, az: number, bx: number, bz: number): number => ax * bz - az * bx;
|
||||
|
||||
function polygonArea(points: ReadonlyArray<P2>): number {
|
||||
let area = 0;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
area += points[j].x * points[i].z - points[i].x * points[j].z;
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
function insidePolygon(points: ReadonlyArray<P2>, p: P2): boolean {
|
||||
let hit = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
const a = points[i];
|
||||
const b = points[j];
|
||||
if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x)
|
||||
hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** 삼각형 안 선 조각 한 끝 — 자리와, 경계 위면 어느 변(0~2) · 꼭짓점(3~5)인가. */
|
||||
interface PieceEnd {
|
||||
u: number;
|
||||
p: P2;
|
||||
on: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 삼각형(꼭짓점 번호 ids)을 선분들로 나눈 면들 — 면마다 [바깥 고리, ...구멍 고리].
|
||||
* 걸친 선이 없으면 null · 퇴화로 못 나누면 변 위 마디만 품은 삼각형 한 면(`fallback`).
|
||||
*/
|
||||
export function splitTriangle(
|
||||
corners: [P2, P2, P2],
|
||||
ids: [number, number, number],
|
||||
segIds: ReadonlyArray<number>,
|
||||
segs: ReadonlyArray<number>,
|
||||
): Array<{ contour: P2[]; holes: P2[][]; fallback?: boolean }> | null {
|
||||
const pieces: Array<{ a: PieceEnd; b: PieceEnd; cuts: Array<{ t: number; p: P2 }> }> = [];
|
||||
/** 변 위 교점 전부 — 조각이 안 남아도(변에 스친 선) 마디로 둔다. 이웃 삼각형도 같은 교점을
|
||||
* 같은 식으로 내므로 양쪽이 늘 같은 마디를 갖는다(T자 균열 0). */
|
||||
const edgeHits: Array<{ e: number; p: P2 }> = [];
|
||||
const triArea = polygonArea(corners);
|
||||
if (Math.abs(triArea) < 1e-10) return null;
|
||||
const sign = Math.sign(triArea);
|
||||
const inside = (p: P2): boolean => {
|
||||
for (let e = 0; e < 3; e += 1) {
|
||||
const a = corners[e];
|
||||
const b = corners[(e + 1) % 3];
|
||||
if (sign * cross(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z) <= 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (const s of segIds) {
|
||||
const P = { x: segs[s * 4], z: segs[s * 4 + 1] };
|
||||
const Q = { x: segs[s * 4 + 2], z: segs[s * 4 + 3] };
|
||||
const ends: PieceEnd[] = [];
|
||||
let collinear = false;
|
||||
for (let e = 0; e < 3 && !collinear; e += 1) {
|
||||
// 변은 꼭짓점 번호 작은 쪽 → 큰 쪽으로 — 이웃 삼각형과 같은 식 · 같은 값.
|
||||
const swap = ids[e] > ids[(e + 1) % 3];
|
||||
const lo = corners[swap ? (e + 1) % 3 : e];
|
||||
const hi = corners[swap ? e : (e + 1) % 3];
|
||||
const ex = hi.x - lo.x;
|
||||
const ez = hi.z - lo.z;
|
||||
const length = Math.hypot(ex, ez);
|
||||
const d1 = cross(ex, ez, P.x - lo.x, P.z - lo.z) / length;
|
||||
const d2 = cross(ex, ez, Q.x - lo.x, Q.z - lo.z) / length;
|
||||
if (Math.abs(d1) < SNAP_M && Math.abs(d2) < SNAP_M) collinear = true;
|
||||
if ((d1 > 0 && d2 > 0) || (d1 < 0 && d2 < 0) || d1 === d2) continue;
|
||||
const u = d1 / (d1 - d2);
|
||||
const x = P.x + (Q.x - P.x) * u;
|
||||
const z = P.z + (Q.z - P.z) * u;
|
||||
const w = ((x - lo.x) * ex + (z - lo.z) * ez) / (length * length);
|
||||
if (w * length < -SNAP_M || (w - 1) * length > SNAP_M) continue;
|
||||
const corner =
|
||||
w * length < SNAP_M
|
||||
? swap
|
||||
? (e + 1) % 3
|
||||
: e
|
||||
: (1 - w) * length < SNAP_M
|
||||
? swap
|
||||
? e
|
||||
: (e + 1) % 3
|
||||
: -1;
|
||||
if (corner >= 0) {
|
||||
ends.push({ u, p: corners[corner], on: [corner + 3, corner, (corner + 2) % 3] });
|
||||
} else {
|
||||
ends.push({ u, p: { x, z }, on: [e] });
|
||||
edgeHits.push({ e, p: { x, z } });
|
||||
}
|
||||
}
|
||||
if (collinear) continue;
|
||||
if (inside(P)) ends.push({ u: 0, p: P, on: [] });
|
||||
if (inside(Q)) ends.push({ u: 1, p: Q, on: [] });
|
||||
if (ends.length < 2) continue;
|
||||
ends.sort((m, n) => m.u - n.u);
|
||||
const a = ends[0];
|
||||
const b = ends[ends.length - 1];
|
||||
if (Math.hypot(a.p.x - b.p.x, a.p.z - b.p.z) < SNAP_M) continue;
|
||||
// 두 끝이 같은 변 위 — 경계를 따라가는 선이라 나눌 것이 없다.
|
||||
if (a.on.some((k) => k < 3 && b.on.includes(k))) continue;
|
||||
pieces.push({ a, b, cuts: [] });
|
||||
}
|
||||
if (!pieces.length && !edgeHits.length) return null;
|
||||
// 선 조각끼리 교차 — 두 조각 모두에 마디를 넣는다.
|
||||
for (let i = 0; i < pieces.length; i += 1) {
|
||||
for (let j = i + 1; j < pieces.length; j += 1) {
|
||||
const A = pieces[i];
|
||||
const B = pieces[j];
|
||||
const rx = A.b.p.x - A.a.p.x;
|
||||
const rz = A.b.p.z - A.a.p.z;
|
||||
const sx = B.b.p.x - B.a.p.x;
|
||||
const sz = B.b.p.z - B.a.p.z;
|
||||
const den = cross(rx, rz, sx, sz);
|
||||
if (Math.abs(den) < 1e-14) continue;
|
||||
const qx = B.a.p.x - A.a.p.x;
|
||||
const qz = B.a.p.z - A.a.p.z;
|
||||
const t = cross(qx, qz, sx, sz) / den;
|
||||
const v = cross(qx, qz, rx, rz) / den;
|
||||
const ta = SNAP_M / Math.hypot(rx, rz);
|
||||
const tb = SNAP_M / Math.hypot(sx, sz);
|
||||
if (t < -ta || t > 1 + ta || v < -tb || v > 1 + tb) continue;
|
||||
const innerA = t > ta && t < 1 - ta;
|
||||
const innerB = v > tb && v < 1 - tb;
|
||||
if (!innerA && !innerB) continue; // 끝끼리 닿음 — 같은 마디로 묶인다.
|
||||
// 한쪽 끝이 다른 조각 가운데에 닿음(T) — 그 끝 자리를 가운데 쪽 조각의 마디로.
|
||||
const p =
|
||||
innerA && innerB
|
||||
? { x: A.a.p.x + rx * t, z: A.a.p.z + rz * t }
|
||||
: innerA
|
||||
? v <= tb
|
||||
? B.a.p
|
||||
: B.b.p
|
||||
: t <= ta
|
||||
? A.a.p
|
||||
: A.b.p;
|
||||
if (innerA) A.cuts.push({ t, p });
|
||||
if (innerB) B.cuts.push({ t: v, p });
|
||||
}
|
||||
}
|
||||
|
||||
// 평면 그래프 — SNAP 안 마디는 하나로 묶는다(거의 겹친 마디는 각 순서를 흔들어 면 걷기를 깬다).
|
||||
const nodes: P2[] = [];
|
||||
const node = (p: P2): number => {
|
||||
const found = nodes.findIndex((q) => Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M * 10);
|
||||
if (found >= 0) return found;
|
||||
nodes.push(p);
|
||||
return nodes.length - 1;
|
||||
};
|
||||
corners.forEach(node);
|
||||
const adj: Array<Set<number>> = [];
|
||||
const link = (i: number, j: number): void => {
|
||||
if (i === j) return;
|
||||
(adj[i] ??= new Set()).add(j);
|
||||
(adj[j] ??= new Set()).add(i);
|
||||
};
|
||||
const onEdge: Array<Array<{ w: number; id: number }>> = [[], [], []];
|
||||
for (const piece of pieces) {
|
||||
const chain = [piece.a.p, ...piece.cuts.sort((m, n) => m.t - n.t).map((c) => c.p), piece.b.p];
|
||||
const idsOf = chain.map(node);
|
||||
for (let k = 1; k < idsOf.length; k += 1) link(idsOf[k - 1], idsOf[k]);
|
||||
}
|
||||
for (const { e, p } of edgeHits) {
|
||||
const from = corners[e];
|
||||
const to = corners[(e + 1) % 3];
|
||||
const w = Math.hypot(p.x - from.x, p.z - from.z) / Math.hypot(to.x - from.x, to.z - from.z);
|
||||
onEdge[e].push({ w, id: node(p) });
|
||||
}
|
||||
for (let e = 0; e < 3; e += 1) {
|
||||
const stops = [
|
||||
{ w: 0, id: e },
|
||||
...onEdge[e].sort((m, n) => m.w - n.w),
|
||||
{ w: 1, id: (e + 1) % 3 },
|
||||
];
|
||||
for (let k = 1; k < stops.length; k += 1) link(stops[k - 1].id, stops[k].id);
|
||||
}
|
||||
/** 퇴화 대비 — 안쪽 선은 버리고 변 위 마디만 품은 한 면(이웃과 T자 균열은 없다). */
|
||||
const boundaryOnly = (): Array<{ contour: P2[]; holes: P2[][]; fallback: boolean }> => [
|
||||
{
|
||||
contour: [0, 1, 2].flatMap((e) => [corners[e], ...onEdge[e].map((stop) => nodes[stop.id])]),
|
||||
holes: [],
|
||||
fallback: true,
|
||||
},
|
||||
];
|
||||
// 매달린 선(한쪽 끝이 삼각형 안에서 끝남)은 면을 안 나눈다 — 걷어 낸다.
|
||||
let pruned = true;
|
||||
while (pruned) {
|
||||
pruned = false;
|
||||
adj.forEach((set, i) => {
|
||||
if (set && set.size === 1) {
|
||||
const [j] = set;
|
||||
adj[j].delete(i);
|
||||
set.clear();
|
||||
pruned = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// 면 걷기 — 들어온 변을 뒤집은 방향에서 시계방향 다음 변. 양(반시계) 면 = 안쪽 면.
|
||||
const order: number[][] = adj.map((set, i) =>
|
||||
set
|
||||
? [...set].sort(
|
||||
(m, n) =>
|
||||
Math.atan2(nodes[m].z - nodes[i].z, nodes[m].x - nodes[i].x) -
|
||||
Math.atan2(nodes[n].z - nodes[i].z, nodes[n].x - nodes[i].x),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
const seen = new Set<number>();
|
||||
const N = nodes.length;
|
||||
const faces: P2[][] = [];
|
||||
const faceIds: number[][] = [];
|
||||
order.forEach((list, i) =>
|
||||
list.forEach((j) => {
|
||||
if (seen.has(i * N + j)) return;
|
||||
const ring: number[] = [];
|
||||
let u = i;
|
||||
let v = j;
|
||||
while (!seen.has(u * N + v) && ring.length <= 4 * N) {
|
||||
seen.add(u * N + v);
|
||||
ring.push(u);
|
||||
const around = order[v];
|
||||
const k = around.indexOf(u);
|
||||
const w = around[(k - 1 + around.length) % around.length];
|
||||
u = v;
|
||||
v = w;
|
||||
}
|
||||
faces.push(ring.map((id) => nodes[id]));
|
||||
faceIds.push(ring);
|
||||
}),
|
||||
);
|
||||
const filled: Array<{ contour: P2[]; holes: P2[][]; ids: Set<number> }> = [];
|
||||
const holes: Array<{ ring: P2[]; ids: number[] }> = [];
|
||||
let total = 0;
|
||||
faces.forEach((ring, f) => {
|
||||
// 면 걷기 규칙상 안쪽 면은 늘 반시계(넓이 +) — 삼각형 감김과 무관.
|
||||
const area = polygonArea(ring);
|
||||
if (area > 1e-12) {
|
||||
filled.push({ contour: ring, holes: [], ids: new Set(faceIds[f]) });
|
||||
total += area;
|
||||
} else if (area < -1e-12 && !faceIds[f].includes(0)) {
|
||||
holes.push({ ring, ids: faceIds[f] });
|
||||
}
|
||||
});
|
||||
// 삼각형 안에 따로 떠 있는 고리(작은 구멍) — 그 고리를 품은 면의 구멍으로.
|
||||
for (const hole of holes) {
|
||||
const host = filled
|
||||
.filter((face) => !hole.ids.some((id) => face.ids.has(id)))
|
||||
.filter((face) => insidePolygon(face.contour, hole.ring[0]))
|
||||
.sort((m, n) => Math.abs(polygonArea(m.contour)) - Math.abs(polygonArea(n.contour)))[0];
|
||||
if (!host) return boundaryOnly();
|
||||
host.holes.push(hole.ring);
|
||||
total -= Math.abs(polygonArea(hole.ring));
|
||||
}
|
||||
// 면 넓이 합이 삼각형과 다르면(퇴화) 나누지 않는다. 면이 하나여도 변 위 마디(매달린 선이
|
||||
// 이웃 삼각형을 나눈 자리)를 품으므로 그대로 낸다 — 통째로 두면 T자 균열.
|
||||
if (Math.abs(total - Math.abs(triArea)) > 1e-6 * Math.max(1, Math.abs(triArea))) {
|
||||
return boundaryOnly();
|
||||
}
|
||||
return filled.map(({ contour, holes: inner }) => ({ contour, holes: inner }));
|
||||
}
|
||||
|
||||
/** 넓이 0 에 가까운 삼각형 — 가장 긴 변에서 맞은 꼭짓점까지 높이가 SNAP 안. */
|
||||
function sliver(a: P2, b: P2, c: P2): boolean {
|
||||
const twice = Math.abs(cross(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z));
|
||||
const longest = Math.max(
|
||||
Math.hypot(b.x - a.x, b.z - a.z),
|
||||
Math.hypot(c.x - b.x, c.z - b.z),
|
||||
Math.hypot(a.x - c.x, a.z - c.z),
|
||||
);
|
||||
return twice < SNAP_M * longest;
|
||||
}
|
||||
|
||||
/**
|
||||
* earcut 이 일직선 점을 걸러 내거나 넓이 0 귀로만 쓴 자리를 되살린다 — 변 위 마디가 빠지면
|
||||
* 이웃 면과 T자 균열. 넓이 0 삼각형은 버리고, 빠진 점을 변 위에 둔 삼각형을 그 점에서 둘로.
|
||||
*/
|
||||
function keepCollinear(ring: ReadonlyArray<P2>, tris: number[][]): number[][] {
|
||||
const out = tris.filter(([a, b, c]) => !sliver(ring[a], ring[b], ring[c])).map((tri) => [...tri]);
|
||||
const used = new Set(out.flat());
|
||||
ring.forEach((p, k) => {
|
||||
if (used.has(k)) return;
|
||||
for (let i = 0; i < out.length; i += 1) {
|
||||
const tri = out[i];
|
||||
for (let e = 0; e < 3; e += 1) {
|
||||
const a = ring[tri[e]];
|
||||
const b = ring[tri[(e + 1) % 3]];
|
||||
const ex = b.x - a.x;
|
||||
const ez = b.z - a.z;
|
||||
const length2 = ex * ex + ez * ez;
|
||||
const along = (p.x - a.x) * ex + (p.z - a.z) * ez;
|
||||
if (along <= 0 || along >= length2) continue;
|
||||
if (Math.abs(cross(ex, ez, p.x - a.x, p.z - a.z)) / Math.sqrt(length2) > 1e-7) continue;
|
||||
const c = tri[(e + 2) % 3];
|
||||
out.splice(i, 1, [tri[e], k, c], [k, tri[(e + 1) % 3], c]);
|
||||
used.add(k);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 보간해 이어 갈 속성 — 위치 · 법선을 뺀 나머지(`_Clip.ts` 와 같은 규칙). */
|
||||
function attributeSlots(geometry: THREE.BufferGeometry): Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
source: THREE.BufferAttribute;
|
||||
values: ArrayLike<number>;
|
||||
extra: number[];
|
||||
}> {
|
||||
return Object.keys(geometry.attributes)
|
||||
.filter((name) => name !== "position" && name !== "normal")
|
||||
.map((name) => {
|
||||
const source = geometry.getAttribute(name) as THREE.BufferAttribute;
|
||||
const values = (
|
||||
source.array as unknown as { subarray(a: number, b: number): ArrayLike<number> }
|
||||
).subarray(0, source.count * source.itemSize);
|
||||
return { name, size: source.itemSize, source, values, extra: [] as number[] };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 지형 한 조각을 브레이크라인으로 다시 나누고 높이를 옮긴 사본 — 칠한 정점 표식과 함께.
|
||||
*/
|
||||
export function retriangulateBand(
|
||||
geometry: THREE.BufferGeometry,
|
||||
ctx: BandContext,
|
||||
stats: BandStats,
|
||||
): { geometry: THREE.BufferGeometry; keep: Uint8Array } {
|
||||
const position = geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const base = position.count;
|
||||
const px = (position.array as Float32Array).subarray(0, base * 3);
|
||||
const positions = Float32Array.from(px);
|
||||
const index = geometry.getIndex();
|
||||
const triCount = index ? index.count / 3 : base / 3;
|
||||
const slots = attributeSlots(geometry);
|
||||
const color = slots.find((slot) => slot.name === "color");
|
||||
const scale = color?.source.normalized
|
||||
? color.source.array instanceof Uint8Array
|
||||
? 255
|
||||
: 65535
|
||||
: 1;
|
||||
const grid = new SegmentGrid(ctx.lines);
|
||||
const kept: number[] = [];
|
||||
const extra: number[] = [];
|
||||
const zones: number[] = [];
|
||||
/** 원 정점 — 0 아직 · 1 원지반 · 2 성토 · 3 절토(+1). */
|
||||
const done = new Uint8Array(base);
|
||||
|
||||
const count = (delta: number): void => {
|
||||
if (Math.abs(delta) < MOVE_MIN_M) return;
|
||||
stats.moved += 1;
|
||||
if (delta > 0) {
|
||||
stats.raised += 1;
|
||||
stats.maxRaiseM = Math.max(stats.maxRaiseM, delta);
|
||||
} else {
|
||||
stats.lowered += 1;
|
||||
stats.maxCutM = Math.max(stats.maxCutM, -delta);
|
||||
}
|
||||
};
|
||||
const settle = (v: number): void => {
|
||||
if (done[v]) return;
|
||||
const y = px[v * 3 + 1];
|
||||
const out = ctx.deform(px[v * 3], y, px[v * 3 + 2]);
|
||||
positions[v * 3 + 1] = out.y;
|
||||
done[v] = out.zone + 1;
|
||||
count(out.y - y);
|
||||
};
|
||||
|
||||
for (let t = 0; t < triCount; t += 1) {
|
||||
const ids: [number, number, number] = [0, 1, 2].map((k) =>
|
||||
index ? index.getX(t * 3 + k) : t * 3 + k,
|
||||
) as [number, number, number];
|
||||
const corners = ids.map((v) => ({ x: px[v * 3], z: px[v * 3 + 2] })) as [P2, P2, P2];
|
||||
const centroid = {
|
||||
x: (corners[0].x + corners[1].x + corners[2].x) / 3,
|
||||
z: (corners[0].z + corners[1].z + corners[2].z) / 3,
|
||||
};
|
||||
if (!corners.some(ctx.touched) && !ctx.touched(centroid)) {
|
||||
kept.push(...ids);
|
||||
continue;
|
||||
}
|
||||
const near = grid.near(
|
||||
Math.min(corners[0].x, corners[1].x, corners[2].x),
|
||||
Math.max(corners[0].x, corners[1].x, corners[2].x),
|
||||
Math.min(corners[0].z, corners[1].z, corners[2].z),
|
||||
Math.max(corners[0].z, corners[1].z, corners[2].z),
|
||||
);
|
||||
const faces = near.length ? splitTriangle(corners, ids, near, grid.segs) : null;
|
||||
if (faces?.some((face) => face.fallback)) stats.fallback += 1;
|
||||
if (!faces) {
|
||||
if (ctx.removed(centroid)) continue;
|
||||
ids.forEach(settle);
|
||||
kept.push(...ids);
|
||||
continue;
|
||||
}
|
||||
stats.split += 1;
|
||||
// 원 삼각형 평면 · 속성 무게중심 보간.
|
||||
const [a, b, c] = corners;
|
||||
const den = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z);
|
||||
const weights = (p: P2): [number, number, number] => {
|
||||
const w0 = ((b.z - c.z) * (p.x - c.x) + (c.x - b.x) * (p.z - c.z)) / den;
|
||||
const w1 = ((c.z - a.z) * (p.x - c.x) + (a.x - c.x) * (p.z - c.z)) / den;
|
||||
return [w0, w1, 1 - w0 - w1];
|
||||
};
|
||||
const sign = Math.sign(den);
|
||||
for (const face of faces) {
|
||||
const ring = [...face.contour, ...face.holes.flat()];
|
||||
const tris = keepCollinear(
|
||||
ring,
|
||||
THREE.ShapeUtils.triangulateShape(
|
||||
face.contour.map((p) => new THREE.Vector2(p.x, p.z)),
|
||||
face.holes.map((hole) => hole.map((p) => new THREE.Vector2(p.x, p.z))),
|
||||
),
|
||||
);
|
||||
for (const tri of tris) {
|
||||
const pts = tri.map((k) => ring[k]);
|
||||
const mid = {
|
||||
x: (pts[0].x + pts[1].x + pts[2].x) / 3,
|
||||
z: (pts[0].z + pts[1].z + pts[2].z) / 3,
|
||||
};
|
||||
if (ctx.removed(mid)) continue;
|
||||
const w = weights(mid);
|
||||
const zone = ctx.deform(
|
||||
mid.x,
|
||||
w[0] * px[ids[0] * 3 + 1] + w[1] * px[ids[1] * 3 + 1] + w[2] * px[ids[2] * 3 + 1],
|
||||
mid.z,
|
||||
).zone;
|
||||
const turn = cross(
|
||||
pts[1].x - pts[0].x,
|
||||
pts[1].z - pts[0].z,
|
||||
pts[2].x - pts[0].x,
|
||||
pts[2].z - pts[0].z,
|
||||
);
|
||||
// 원 삼각형과 같은 감김(앞면 그대로).
|
||||
const ordered = Math.sign(turn) === sign ? pts : [pts[0], pts[2], pts[1]];
|
||||
for (const p of ordered) {
|
||||
const pw = weights(p);
|
||||
const y =
|
||||
pw[0] * px[ids[0] * 3 + 1] + pw[1] * px[ids[1] * 3 + 1] + pw[2] * px[ids[2] * 3 + 1];
|
||||
const out = ctx.deform(p.x, y, p.z);
|
||||
count(out.y - y);
|
||||
kept.push(base + extra.length / 3);
|
||||
extra.push(p.x, out.y, p.z);
|
||||
zones.push(zone);
|
||||
for (const slot of slots) {
|
||||
for (let k = 0; k < slot.size; k += 1) {
|
||||
slot.extra.push(
|
||||
pw[0] * slot.values[ids[0] * slot.size + k] +
|
||||
pw[1] * slot.values[ids[1] * slot.size + k] +
|
||||
pw[2] * slot.values[ids[2] * slot.size + k],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = new THREE.BufferGeometry();
|
||||
const all = new Float32Array(base * 3 + extra.length);
|
||||
all.set(positions, 0);
|
||||
all.set(extra, base * 3);
|
||||
out.setAttribute("position", new THREE.BufferAttribute(all, 3));
|
||||
const keep = new Uint8Array(base + zones.length);
|
||||
const paint = (values: { [i: number]: number }, v: number, zone: number): void => {
|
||||
if (!color || zone < 1) return;
|
||||
const rgb = ctx.tint(zone as 1 | 2);
|
||||
for (let k = 0; k < 3; k += 1)
|
||||
values[v * color.size + k] = scale === 1 ? rgb[k] : Math.round(rgb[k] * scale);
|
||||
keep[v] = 1;
|
||||
};
|
||||
for (const slot of slots) {
|
||||
const ctor = slot.source.array.constructor as new (length: number) => THREE.TypedArray;
|
||||
const merged = new ctor(base * slot.size + slot.extra.length);
|
||||
merged.set(slot.values as ArrayLike<number>, 0);
|
||||
merged.set(
|
||||
slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra,
|
||||
base * slot.size,
|
||||
);
|
||||
if (slot === color) {
|
||||
for (let v = 0; v < base; v += 1) paint(merged, v, done[v] - 1);
|
||||
zones.forEach((zone, i) => paint(merged, base + i, zone));
|
||||
}
|
||||
out.setAttribute(
|
||||
slot.name,
|
||||
new THREE.BufferAttribute(merged, slot.size, slot.source.normalized),
|
||||
);
|
||||
}
|
||||
out.setIndex(kept);
|
||||
out.computeBoundingSphere();
|
||||
out.computeBoundingBox();
|
||||
return { geometry: out, keep };
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures";
|
||||
import {
|
||||
classifyStation,
|
||||
PIECE_COLS,
|
||||
@@ -164,14 +164,39 @@ function setOf(
|
||||
return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6);
|
||||
}
|
||||
|
||||
/** 지형 구멍 고리(모델 좌표) — 구조물 풋프린트 · BOX 아령 외곽. 도로부 안쪽은 뺀다. */
|
||||
/** 윗면으로 볼 높이 여유(m) — 링 폴리곤 최고점에서 이만큼 안. */
|
||||
const TOP_TOLERANCE_M = 0.05;
|
||||
/** 구조물 둘레 드레이프 띠(m) — 풋프린트 밖 이만큼까지 지형을 윗면 아래로(115-5 P1-4). */
|
||||
export const DRAPE_BAND_M = 0.75;
|
||||
/** 관 입구 구멍 — 끝에서 안쪽으로 볼 길이 · 걸음(m). */
|
||||
const MOUTH_REACH_M = 3;
|
||||
const MOUTH_STEP_M = 0.1;
|
||||
|
||||
/** 링 폴리곤의 윗면 점만 — 기초 · 뒷굽은 빼고 솔리드 윗면 외곽만 구멍으로(115-5 P1-4). */
|
||||
function topOnly(polygon: ReadonlyArray<[number, number]>): Array<[number, number]> {
|
||||
const top = Math.max(...polygon.map((point) => point[1]));
|
||||
return polygon.filter((point) => point[1] >= top - TOP_TOLERANCE_M);
|
||||
}
|
||||
|
||||
function topStructure(structure: CorridorStructure): CorridorStructure {
|
||||
return {
|
||||
...structure,
|
||||
polygon: structure.polygon && topOnly(structure.polygon),
|
||||
polygons: structure.polygons?.map(topOnly),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지형 구멍 고리(모델 좌표) — 구조물 **윗면** 외곽 · BOX 아령 외곽. 도로부 안쪽은 뺀다.
|
||||
* 기초 · 뒷굽은 지형이 묻는다(둘레 드레이프 `structureTopAt`).
|
||||
*/
|
||||
export function structureHoleLoops(
|
||||
sections: ReadonlyArray<CrossSection>,
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): Array<Array<[number, number]>> {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
for (const section of sections) {
|
||||
const set = setOf(section, structures);
|
||||
const set = setOf(section, structures).map(topStructure);
|
||||
const edges = section.design?.road_edges;
|
||||
if (!set.length || !edges) continue;
|
||||
(["left", "right"] as const).forEach((side) => {
|
||||
@@ -248,3 +273,158 @@ export function noFillLimits(
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
/** 한 구조물의 링 사이 마디 — 둘레 드레이프 조회용. */
|
||||
interface DrapeSpan {
|
||||
a: StructureFrame;
|
||||
b: StructureFrame;
|
||||
/** 링마다 [편거리 lo, hi, 윗면 표고(dz 포함)]. */
|
||||
wa: [number, number, number];
|
||||
wb: [number, number, number];
|
||||
/** 끝 마디 밖으로 늘릴 비율(띠 / 링 간격). */
|
||||
before: number;
|
||||
after: number;
|
||||
box: [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 둘레 드레이프(115-5 P1-4) — 풋프린트 안 · 둘레 띠 자리의 솔리드 윗면 표고(모델
|
||||
* 좌표). 지형은 `min(z', 윗면)` — 절토 어깨 · 성토가 벽 상단에서 멈춘다. 없으면 null.
|
||||
* 겹친 부재는 가장 높은 윗면. 솔리드는 읽기만 한다.
|
||||
*/
|
||||
export function structureTopAt(
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
): (x: number, y: number) => number | null {
|
||||
const spans: DrapeSpan[] = [];
|
||||
for (const structure of structures) {
|
||||
const rings = structure.rings;
|
||||
if (structure.kind === "pipe" || !rings || rings.length < 2) continue;
|
||||
const window = (index: number): [number, number, number] | null => {
|
||||
const polygon = structure.polygons?.[index] ?? structure.polygon;
|
||||
if (!polygon?.length) return null;
|
||||
const offsets = polygon.map((point) => point[0]);
|
||||
const top = Math.max(...polygon.map((point) => point[1]));
|
||||
return [Math.min(...offsets), Math.max(...offsets), top + rings[index].dz];
|
||||
};
|
||||
for (let i = 0; i < rings.length - 1; i += 1) {
|
||||
const wa = window(i);
|
||||
const wb = window(i + 1);
|
||||
if (!wa || !wb) continue;
|
||||
const a = rings[i];
|
||||
const b = rings[i + 1];
|
||||
const gap = Math.hypot(b.cx - a.cx, b.cy - a.cy);
|
||||
if (gap < 1e-6) continue;
|
||||
const corners = [a, b].flatMap((frame, k) => {
|
||||
const w = k ? wb : wa;
|
||||
return [w[0] - DRAPE_BAND_M, w[1] + DRAPE_BAND_M].map((o) => [
|
||||
frame.cx + frame.leftX * o,
|
||||
frame.cy + frame.leftY * o,
|
||||
]);
|
||||
});
|
||||
const xs = corners.map((p) => p[0]);
|
||||
const ys = corners.map((p) => p[1]);
|
||||
spans.push({
|
||||
a,
|
||||
b,
|
||||
wa,
|
||||
wb,
|
||||
before: i === 0 ? DRAPE_BAND_M / gap : 0,
|
||||
after: i === rings.length - 2 ? DRAPE_BAND_M / gap : 0,
|
||||
box: [
|
||||
Math.min(...xs) - DRAPE_BAND_M,
|
||||
Math.max(...xs) + DRAPE_BAND_M,
|
||||
Math.min(...ys) - DRAPE_BAND_M,
|
||||
Math.max(...ys) + DRAPE_BAND_M,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
// 칸 색인 — 정점마다 마디 전부를 훑지 않는다.
|
||||
const CELL = 4;
|
||||
const cells = new Map<string, DrapeSpan[]>();
|
||||
for (const span of spans) {
|
||||
const [x0, x1, y0, y1] = span.box;
|
||||
for (let c = Math.floor(x0 / CELL); c <= Math.floor(x1 / CELL); c += 1) {
|
||||
for (let g = Math.floor(y0 / CELL); g <= Math.floor(y1 / CELL); g += 1) {
|
||||
const key = `${c},${g}`;
|
||||
const list = cells.get(key);
|
||||
if (list) list.push(span);
|
||||
else cells.set(key, [span]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (x, y) => {
|
||||
let best: number | null = null;
|
||||
for (const span of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) {
|
||||
const [x0, x1, y0, y1] = span.box;
|
||||
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
|
||||
const { a, b, wa, wb } = span;
|
||||
const along = (f: StructureFrame): number => (x - f.cx) * f.leftY - (y - f.cy) * f.leftX;
|
||||
const a0 = along(a);
|
||||
const a1 = along(b);
|
||||
if (Math.abs(a0 - a1) < 1e-12) continue;
|
||||
const t = a0 / (a0 - a1);
|
||||
if (t < -span.before || t > 1 + span.after) continue;
|
||||
const k = Math.min(1, Math.max(0, t));
|
||||
const oa = (x - a.cx) * a.leftX + (y - a.cy) * a.leftY;
|
||||
const ob = (x - b.cx) * b.leftX + (y - b.cy) * b.leftY;
|
||||
const o = oa + (ob - oa) * k;
|
||||
const lo = wa[0] + (wb[0] - wa[0]) * k - DRAPE_BAND_M;
|
||||
const hi = wa[1] + (wb[1] - wa[1]) * k + DRAPE_BAND_M;
|
||||
if (o < lo || o > hi) continue;
|
||||
const top = wa[2] + (wb[2] - wa[2]) * k;
|
||||
if (best === null || top > best) best = top;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 입구 구멍(115-5 P1-4) — 관 끝에서 안쪽으로 관 머리가 변형 지형 위로 드러난 만큼의
|
||||
* 관경 폭 사각 고리(모델 좌표). 묻힌 끝은 구멍 없음.
|
||||
*/
|
||||
export function pipeMouthLoops(
|
||||
structures: ReadonlyArray<CorridorStructure>,
|
||||
groundAfter: (x: number, y: number) => number | null,
|
||||
): Array<Array<[number, number]>> {
|
||||
const loops: Array<Array<[number, number]>> = [];
|
||||
for (const structure of structures) {
|
||||
const { pipe, frame } = structure;
|
||||
if (structure.kind !== "pipe" || !pipe || !frame) continue;
|
||||
const length = Math.abs(pipe.end[0] - pipe.start[0]);
|
||||
if (length < 0.2) continue;
|
||||
const half = pipe.diameterM / 2;
|
||||
const plan = (o: number, lateral: number): [number, number] => [
|
||||
frame.cx + frame.leftX * o + frame.leftY * lateral,
|
||||
frame.cy + frame.leftY * o - frame.leftX * lateral,
|
||||
];
|
||||
const crownAt = (o: number): number => {
|
||||
const t = (o - pipe.start[0]) / (pipe.end[0] - pipe.start[0]);
|
||||
return pipe.start[1] + (pipe.end[1] - pipe.start[1]) * t + pipe.diameterM;
|
||||
};
|
||||
for (const [from, to] of [
|
||||
[pipe.start[0], pipe.end[0]],
|
||||
[pipe.end[0], pipe.start[0]],
|
||||
]) {
|
||||
const dir = Math.sign(to - from);
|
||||
let reach = 0;
|
||||
for (let d = 0; d <= Math.min(MOUTH_REACH_M, length / 2) + 1e-9; d += MOUTH_STEP_M) {
|
||||
const o = from + dir * d;
|
||||
const [x, y] = plan(o, 0);
|
||||
const ground = groundAfter(x, y);
|
||||
if (ground === null || crownAt(o) <= ground) break;
|
||||
reach = d;
|
||||
}
|
||||
if (reach < MOUTH_STEP_M) continue;
|
||||
const inner = from + dir * reach;
|
||||
loops.push([
|
||||
plan(from, -half),
|
||||
plan(inner, -half),
|
||||
plan(inner, half),
|
||||
plan(from, half),
|
||||
plan(from, -half),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return loops;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Deform_Terrain.ts
|
||||
* 새 방식 지형 — 밴드 사본을 노면부 바깥선에서 자르고(`clipGeometry`) 사본 정점의
|
||||
* 높이만 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1).
|
||||
* 새 방식 지형 — 밴드 사본에 브레이크라인(노면 끝 · 비탈 끝 · 단면 꺾임 · 구조물 윗면
|
||||
* 외곽 · 관 입구)을 끼워 다시 삼각분할하고(`_Deform_Breakline.ts`) 사본 정점의 높이만
|
||||
* 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1 · 115-5).
|
||||
*
|
||||
* 원본 · 분할본 정점 버퍼는 건드리지 않는다 — `clipGeometry` 가 새 위치 배열을 만들고,
|
||||
* 바뀐 것이 없을 땐 여기서 복제한다. [예상형상] OFF 면 뷰어가 원본을 그대로 보인다.
|
||||
* 법선은 새로 만들지 않는다(감기 방향 뒤집혀 검게 된 전례 · `_Clip.ts`).
|
||||
* 색 = 높이 변화 부호 — 절토 · 성토 색(코리도 리본과 같은 색), 빗금 없음.
|
||||
* 원본 · 분할본 정점 버퍼는 건드리지 않는다 — 새 위치 배열을 만든다. [예상형상] OFF 면
|
||||
* 뷰어가 원본을 그대로 보인다. 법선은 새로 만들지 않는다(`_Clip.ts`).
|
||||
* 색 = 자리(비탈 끝 안 성토 · 절토) — 면 단위 · 경계 = 비탈 끝 선 · 빗금 없음.
|
||||
* 구조물 둘레는 솔리드 윗면 아래로 드레이프(115-5 P1-4). 손대는 자리는 행별 비탈 끝 + 5 m
|
||||
* 띠(P2-5) — 그 밖 지형은 1 mm 도 안 움직인다.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
@@ -14,13 +16,7 @@ import type { ModelBounds } from "./B05_Profile_UI_Markers";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types";
|
||||
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
|
||||
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
|
||||
import { clipGeometry } from "./B05_Profile_UI_Corridor_Clip";
|
||||
import {
|
||||
CellRegion,
|
||||
PlanLoopRegion,
|
||||
scenePlanMapper,
|
||||
type PlanRegion,
|
||||
} from "./B05_Profile_UI_Corridor_Region";
|
||||
import { CellRegion, scenePlanMapper, type P2 } from "./B05_Profile_UI_Corridor_Region";
|
||||
import {
|
||||
buildDeformField,
|
||||
stationProfileSource,
|
||||
@@ -29,15 +25,25 @@ import {
|
||||
} from "./B05_Profile_UI_Corridor_Deform";
|
||||
import {
|
||||
noFillLimits,
|
||||
pipeMouthLoops,
|
||||
structureHoleLoops,
|
||||
structureSlopes,
|
||||
structureTopAt,
|
||||
} from "./B05_Profile_UI_Corridor_Deform_Structure";
|
||||
import {
|
||||
retriangulateBand,
|
||||
type BandStats,
|
||||
type Breakline,
|
||||
} from "./B05_Profile_UI_Corridor_Deform_Breakline";
|
||||
|
||||
/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */
|
||||
const CUT_COLOR = new THREE.Color(0xb5179e);
|
||||
const FILL_COLOR = new THREE.Color(0x06b6d4);
|
||||
/** 이보다 작게 움직인 정점은 원지반 색 그대로(m). */
|
||||
const TINT_MIN_M = 0.01;
|
||||
/** 손대는 띠 — 비탈 끝 밖 여유(m) · 칸(m). */
|
||||
const BAND_PAD_M = 5;
|
||||
const MASK_CELL_M = 2;
|
||||
/** 비탈 끝 · 꺾임 선으로 세울 최소 거리(m) — 앵커 · 비탈 끝에 겹친 선은 세우지 않는다. */
|
||||
const LINE_MIN_M = 0.05;
|
||||
|
||||
/** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */
|
||||
class RoadStrip extends CellRegion {
|
||||
@@ -58,62 +64,140 @@ class RoadStrip extends CellRegion {
|
||||
}
|
||||
|
||||
/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */
|
||||
export interface DeformStats {
|
||||
export interface DeformStats extends BandStats {
|
||||
rows: number;
|
||||
holes: number;
|
||||
moved: number;
|
||||
raised: number;
|
||||
lowered: number;
|
||||
maxRaiseM: number;
|
||||
maxCutM: number;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사본 geometry 정점 높이를 옮기고 색을 칠한다. 씬 좌표(Y-up) ↔ 모델 좌표는 뷰어 규약.
|
||||
* 칠한 정점은 `userData.keepColor` 에 표시해 흑백 지형 토글이 덮지 않게 한다.
|
||||
*/
|
||||
function deformGeometry(
|
||||
geometry: THREE.BufferGeometry,
|
||||
/** 브레이크라인 — 노면 끝(앵커 · 마구리) · 행별 비탈 끝 · 단면 꺾임(씬 평면). */
|
||||
export function deformBreaklines(
|
||||
field: DeformField,
|
||||
bounds: ModelBounds,
|
||||
stats: DeformStats,
|
||||
): Uint8Array {
|
||||
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;
|
||||
const position = geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined;
|
||||
const keep = new Uint8Array(position.count);
|
||||
for (let v = 0; v < position.count; v += 1) {
|
||||
const sceneY = position.getY(v);
|
||||
const z = sceneY + oz;
|
||||
const next = field.deformZ(position.getX(v) + ox, -position.getZ(v) + oy, z);
|
||||
const delta = next - z;
|
||||
if (Math.abs(delta) < TINT_MIN_M) continue;
|
||||
position.setY(v, next - oz);
|
||||
stats.moved += 1;
|
||||
if (delta > 0) {
|
||||
stats.raised += 1;
|
||||
stats.maxRaiseM = Math.max(stats.maxRaiseM, delta);
|
||||
} else {
|
||||
stats.lowered += 1;
|
||||
stats.maxCutM = Math.max(stats.maxCutM, -delta);
|
||||
}
|
||||
if (color) {
|
||||
const tint = delta > 0 ? FILL_COLOR : CUT_COLOR;
|
||||
color.setXYZ(v, tint.r, tint.g, tint.b);
|
||||
keep[v] = 1;
|
||||
toScene: (x: number, y: number) => P2,
|
||||
): Breakline[] {
|
||||
const rows = field.rows;
|
||||
const n = rows.length;
|
||||
const lines: Breakline[] = [
|
||||
rows.map((row) => toScene(row.left[0], row.left[1])),
|
||||
rows.map((row) => toScene(row.right[0], row.right[1])),
|
||||
[toScene(rows[0].left[0], rows[0].left[1]), toScene(rows[0].right[0], rows[0].right[1])],
|
||||
[
|
||||
toScene(rows[n - 1].left[0], rows[n - 1].left[1]),
|
||||
toScene(rows[n - 1].right[0], rows[n - 1].right[1]),
|
||||
],
|
||||
];
|
||||
const at = (r: number, si: number, s: number): P2 => toScene(...field.pointAt(r, si, s));
|
||||
for (let si = 0; si < 2; si += 1) {
|
||||
for (let ki = 0; ki < 2; ki += 1) {
|
||||
let run: P2[] = [];
|
||||
const runs = new Map<number, P2[]>();
|
||||
const flush = (line: P2[]): void => {
|
||||
if (line.length >= 2) lines.push(line);
|
||||
};
|
||||
let start = -1;
|
||||
for (let r = 0; r < n; r += 1) {
|
||||
const reach = field.catchAt(r, si, ki);
|
||||
if (reach > LINE_MIN_M) {
|
||||
if (!run.length) start = r;
|
||||
run.push(at(r, si, reach));
|
||||
}
|
||||
// 비탈이 시작 · 끝나는 행 — 앵커 → 비탈 끝 마구리 선(색 경계 · 끝 단차).
|
||||
const ends = run.length && (reach <= LINE_MIN_M || r === n - 1);
|
||||
if (ends) {
|
||||
const last = reach > LINE_MIN_M ? r : r - 1;
|
||||
for (const k of new Set([start, last])) {
|
||||
lines.push([at(k, si, 0), at(k, si, field.catchAt(k, si, ki))]);
|
||||
}
|
||||
}
|
||||
if (reach <= LINE_MIN_M) {
|
||||
flush(run);
|
||||
run = [];
|
||||
}
|
||||
const cols = new Set<number>();
|
||||
for (const [col, s] of field.bendsAt(r, si, ki)) {
|
||||
if (s < LINE_MIN_M || s > reach - LINE_MIN_M) continue;
|
||||
cols.add(col);
|
||||
const line = runs.get(col) ?? [];
|
||||
line.push(at(r, si, s));
|
||||
runs.set(col, line);
|
||||
}
|
||||
runs.forEach((line, col) => {
|
||||
if (cols.has(col)) return;
|
||||
flush(line);
|
||||
runs.delete(col);
|
||||
});
|
||||
}
|
||||
flush(run);
|
||||
runs.forEach(flush);
|
||||
}
|
||||
}
|
||||
position.needsUpdate = true;
|
||||
if (color) color.needsUpdate = true;
|
||||
geometry.computeBoundingSphere();
|
||||
geometry.computeBoundingBox();
|
||||
return keep;
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 손대는 자리 — 행마다 노면부 + 좌우 비탈 끝 + 5 m 띠, 구멍 상자 둘레(P2-5). */
|
||||
function bandMask(
|
||||
field: DeformField,
|
||||
toScene: (x: number, y: number) => P2,
|
||||
holes: ReadonlyArray<P2[]>,
|
||||
): (p: P2) => boolean {
|
||||
const marks: P2[] = [];
|
||||
field.rows.forEach((row, r) => {
|
||||
const width = Math.hypot(row.left[0] - row.right[0], row.left[1] - row.right[1]);
|
||||
const reach = (si: number): number =>
|
||||
Math.max(field.catchAt(r, si, 0), field.catchAt(r, si, 1)) + BAND_PAD_M;
|
||||
for (let s = -width; s <= reach(0); s += 1) marks.push(toScene(...field.pointAt(r, 0, s)));
|
||||
for (let s = 0; s <= reach(1); s += 1) marks.push(toScene(...field.pointAt(r, 1, s)));
|
||||
});
|
||||
holes.forEach((loop) => {
|
||||
const xs = loop.map((p) => p.x);
|
||||
const zs = loop.map((p) => p.z);
|
||||
for (let x = Math.min(...xs) - 1.5; x <= Math.max(...xs) + 1.5; x += 1) {
|
||||
for (let z = Math.min(...zs) - 1.5; z <= Math.max(...zs) + 1.5; z += 1) marks.push({ x, z });
|
||||
}
|
||||
});
|
||||
if (!marks.length) return () => false;
|
||||
let minX = Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxZ = -Infinity;
|
||||
for (const p of marks) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minZ = Math.min(minZ, p.z);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxZ = Math.max(maxZ, p.z);
|
||||
}
|
||||
minX -= MASK_CELL_M * 2;
|
||||
minZ -= MASK_CELL_M * 2;
|
||||
const cols = Math.ceil((maxX - minX) / MASK_CELL_M) + 3;
|
||||
const lines = Math.ceil((maxZ - minZ) / MASK_CELL_M) + 3;
|
||||
const mask = new Uint8Array(cols * lines);
|
||||
for (const p of marks) {
|
||||
const c = Math.floor((p.x - minX) / MASK_CELL_M);
|
||||
const g = Math.floor((p.z - minZ) / MASK_CELL_M);
|
||||
for (let dg = -1; dg <= 1; dg += 1) {
|
||||
for (let dc = -1; dc <= 1; dc += 1) mask[(g + dg) * cols + c + dc] = 1;
|
||||
}
|
||||
}
|
||||
return (p) => {
|
||||
const c = Math.floor((p.x - minX) / MASK_CELL_M);
|
||||
const g = Math.floor((p.z - minZ) / MASK_CELL_M);
|
||||
return c >= 0 && g >= 0 && c < cols && g < lines && mask[g * cols + c] === 1;
|
||||
};
|
||||
}
|
||||
|
||||
function insideLoop(loop: ReadonlyArray<P2>, p: P2): boolean {
|
||||
let hit = false;
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
|
||||
const a = loop[i];
|
||||
const b = loop[j];
|
||||
if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x)
|
||||
hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 잘라 변형한다. 변형 장을 못 세우면
|
||||
* 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 다시 나눠 변형한다. 변형 장을 못 세우면
|
||||
* (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다.
|
||||
*/
|
||||
export function deformTerrain(
|
||||
@@ -136,19 +220,69 @@ export function deformTerrain(
|
||||
const source = stationProfileSource(sections, structureSlopes(sections));
|
||||
const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures));
|
||||
if (!field) return null;
|
||||
// 지형 구멍 = 노면부 띠 + 구조물 풋프린트 · BOX 아령 외곽 — 솔리드가 그 자리를 메운다.
|
||||
const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds)];
|
||||
const holes = new PlanLoopRegion(structureHoleLoops(sections, structures), bounds);
|
||||
if (!holes.isEmpty) regions.push(holes);
|
||||
const toScene = scenePlanMapper(bounds);
|
||||
// 지형 구멍 = 노면부 띠 + 구조물 윗면 외곽 · BOX 아령 외곽 + 관 입구 — 솔리드가 메운다.
|
||||
const road = new RoadStrip(field.rows, bounds);
|
||||
const groundAfter = (x: number, y: number): number | null => {
|
||||
const ground = groundAt(x, y);
|
||||
return ground === null ? null : field.deformZ(x, y, ground);
|
||||
};
|
||||
const holes = [
|
||||
...structureHoleLoops(sections, structures),
|
||||
...pipeMouthLoops(structures, groundAfter),
|
||||
]
|
||||
.map((loop) => loop.map(([x, y]) => toScene(x, y)))
|
||||
.filter((loop) => loop.length >= 3);
|
||||
const closed = holes.map((loop) => {
|
||||
const [first] = loop;
|
||||
const last = loop[loop.length - 1];
|
||||
return first.x === last.x && first.z === last.z ? loop : [...loop, first];
|
||||
});
|
||||
const lines = [...deformBreaklines(field, toScene), ...closed];
|
||||
const boxes = holes.map((loop) => {
|
||||
const xs = loop.map((p) => p.x);
|
||||
const zs = loop.map((p) => p.z);
|
||||
return [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)];
|
||||
});
|
||||
const topAt = structureTopAt(structures);
|
||||
const stats: DeformStats = {
|
||||
rows: field.rows.length,
|
||||
holes: regions.length - 1,
|
||||
holes: holes.length,
|
||||
lines: lines.length,
|
||||
split: 0,
|
||||
fallback: 0,
|
||||
moved: 0,
|
||||
raised: 0,
|
||||
lowered: 0,
|
||||
maxRaiseM: 0,
|
||||
maxCutM: 0,
|
||||
};
|
||||
const context = {
|
||||
lines,
|
||||
removed: (p: P2): boolean =>
|
||||
road.contains(p) ||
|
||||
holes.some(
|
||||
(loop, i) =>
|
||||
p.x >= boxes[i][0] &&
|
||||
p.x <= boxes[i][1] &&
|
||||
p.z >= boxes[i][2] &&
|
||||
p.z <= boxes[i][3] &&
|
||||
insideLoop(loop, p),
|
||||
),
|
||||
touched: bandMask(field, toScene, holes),
|
||||
deform: (x: number, y: number, z: number) => {
|
||||
const mx = x + ox;
|
||||
const my = -z + oy;
|
||||
const at = field.deformAt(mx, my, y + oz);
|
||||
const top = topAt(mx, my);
|
||||
const out = top !== null && at.z > top ? top : at.z;
|
||||
return { y: out - oz, zone: at.zone };
|
||||
},
|
||||
tint: (zone: 1 | 2): [number, number, number] => {
|
||||
const color = zone === 1 ? FILL_COLOR : CUT_COLOR;
|
||||
return [color.r, color.g, color.b];
|
||||
},
|
||||
};
|
||||
const root = new THREE.Group();
|
||||
root.name = "terrain-deformed";
|
||||
split.near.forEach((part) => {
|
||||
@@ -159,10 +293,19 @@ export function deformTerrain(
|
||||
root.add(points);
|
||||
return;
|
||||
}
|
||||
const geometry = clipGeometry(part.geometry, regions) ?? part.geometry.clone();
|
||||
const { geometry, keep } = retriangulateBand(part.geometry, context, stats);
|
||||
const normal = part.geometry.getAttribute("normal") as THREE.BufferAttribute | undefined;
|
||||
if (normal) {
|
||||
// 법선은 원본 것만 물려받는다 — 새 정점은 위 방향(`_Clip.ts` 와 같은 규칙).
|
||||
const count = geometry.getAttribute("position").count;
|
||||
const normals = new Float32Array(count * 3);
|
||||
normals.set((normal.array as Float32Array).subarray(0, normal.count * 3), 0);
|
||||
for (let v = normal.count; v < count; v += 1) normals[v * 3 + 1] = 1;
|
||||
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
||||
}
|
||||
const mesh = new THREE.Mesh(geometry, part.material);
|
||||
mesh.userData.owned = true;
|
||||
mesh.userData.keepColor = deformGeometry(geometry, field, bounds, stats);
|
||||
mesh.userData.keepColor = keep;
|
||||
root.add(mesh);
|
||||
});
|
||||
split.far.forEach((part) => {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Modes.ts
|
||||
* 3D 예상형상 **방식 등록부**(계획서 115-5) — 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① ·
|
||||
* 제안 ②」 고르기 한 줄이 이 표를 본다.
|
||||
*
|
||||
* 방식 하나 = 이름 + 만들기 함수 둘 — `corridor`(코리도 그룹에 그릴 빌드) · `terrain`(지형
|
||||
* · 없으면 그 빌드로 띠만 자른다). 제안 ② 는 다른 창이 새 파일을 만들고 아래
|
||||
* `CORRIDOR_MODES` 에 한 줄 등록한다. 등록이 없으면 그 단추는 꺼진다.
|
||||
* 설정 키는 `corridor-mode` 하나 — 옛 두 단추(115-1 `corridor-deform` · 115-4
|
||||
* `corridor-legacy`) 값은 읽어서 옮긴다. 기본 = 새 ①.
|
||||
* ========================================================================== */
|
||||
|
||||
import type * as THREE from "three";
|
||||
import type { ModelBounds } from "./B05_Profile_UI_Markers";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
|
||||
import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split";
|
||||
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
|
||||
import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain";
|
||||
import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
|
||||
/** 고르기 한 줄의 자리 — 순서 · 이름. 등록이 없는 자리는 단추가 꺼진다. */
|
||||
export const MODE_SLOTS = [
|
||||
["current", "지금"],
|
||||
["legacy-a", "옛 A"],
|
||||
["new1", "새 ①"],
|
||||
["proposal2", "제안 ②"],
|
||||
] as const;
|
||||
|
||||
export type CorridorModeId = (typeof MODE_SLOTS)[number][0];
|
||||
|
||||
export const DEFAULT_MODE: CorridorModeId = "new1";
|
||||
|
||||
/** 만들기 함수가 받는 것 — 모델 좌표 범위와 지형 높이 색인(없으면 null). */
|
||||
export interface CorridorModeContext {
|
||||
bounds: ModelBounds;
|
||||
heightIndex: TerrainHeightIndex | null;
|
||||
}
|
||||
|
||||
export interface CorridorModeTerrainInput extends CorridorModeContext {
|
||||
/** 본체 빌드(종횡단 정본 그대로 · 읽기만). */
|
||||
build: CorridorBuildResult;
|
||||
/** 이 방식의 `corridor` 가 낸 빌드. */
|
||||
corridor: CorridorBuildResult;
|
||||
/** 지형 밴드 분할본(near/far) — 읽기만 · 사본을 만들어 쓴다. */
|
||||
split: TerrainBandSplit;
|
||||
}
|
||||
|
||||
export interface CorridorMode {
|
||||
id: CorridorModeId;
|
||||
/** 처음 만들기가 무거운가 — 참이면 알림을 먼저 띄우고 다음 틈에 만든다. */
|
||||
slow?: (build: CorridorBuildResult) => boolean;
|
||||
/** 코리도 그룹에 그릴 빌드 — null 이면 기본 방식으로 그린다. */
|
||||
corridor: (build: CorridorBuildResult, ctx: CorridorModeContext) => CorridorBuildResult | null;
|
||||
/** 지형 — 없거나 null 이면 `corridor` 빌드로 띠만 자른다(`clipTerrain`). */
|
||||
terrain?: (input: CorridorModeTerrainInput) => { root: THREE.Object3D; stats?: unknown } | null;
|
||||
}
|
||||
|
||||
/** 모델 좌표 원지반 높이 — 색인이 없으면 null. */
|
||||
function groundOf(ctx: CorridorModeContext): ((x: number, y: number) => number | null) | null {
|
||||
const index = ctx.heightIndex;
|
||||
if (!index) return null;
|
||||
const ox = (ctx.bounds.x[0] + ctx.bounds.x[1]) / 2;
|
||||
const oy = (ctx.bounds.y[0] + ctx.bounds.y[1]) / 2;
|
||||
const oz = (ctx.bounds.z[0] + ctx.bounds.z[1]) / 2;
|
||||
return (x, y) => {
|
||||
const height = index.heightAt(x - ox, -(y - oy));
|
||||
return height === null ? null : height + oz;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지.
|
||||
* 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부
|
||||
* 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록).
|
||||
*/
|
||||
function snapCorridorEdges(build: CorridorBuildResult, ctx: CorridorModeContext): void {
|
||||
const groundAt = groundOf(ctx);
|
||||
if (!groundAt) return;
|
||||
build.ribbons.forEach((ribbon) => {
|
||||
// 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시).
|
||||
// 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다.
|
||||
if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return;
|
||||
// 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체
|
||||
// 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그
|
||||
// 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m).
|
||||
if (ribbon.patch) return;
|
||||
const cols = ribbon.colCount;
|
||||
const outerCol = ribbon.side === "right" ? 0 : cols - 1;
|
||||
const innerCol = ribbon.side === "right" ? cols - 1 : 0;
|
||||
for (let row = 0; row < ribbon.chainages.length; row += 1) {
|
||||
const outer = (row * cols + outerCol) * 3;
|
||||
const inner = (row * cols + innerCol) * 3;
|
||||
// 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다.
|
||||
const width = Math.hypot(
|
||||
ribbon.positions[outer] - ribbon.positions[inner],
|
||||
ribbon.positions[outer + 1] - ribbon.positions[inner + 1],
|
||||
);
|
||||
if (width < 1e-4) continue;
|
||||
const height = groundAt(ribbon.positions[outer], ribbon.positions[outer + 1]);
|
||||
if (height !== null) ribbon.positions[outer + 2] = height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을
|
||||
* 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은
|
||||
* `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다.
|
||||
*/
|
||||
function attachPatchSkirts(build: CorridorBuildResult, ctx: CorridorModeContext): void {
|
||||
const groundAt = groundOf(ctx);
|
||||
if (!groundAt) return;
|
||||
const { walls, stats } = buildPatchSkirts(build.ribbons, groundAt);
|
||||
const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt);
|
||||
build.cutWalls = [...kept, ...walls];
|
||||
(window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats;
|
||||
}
|
||||
|
||||
/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */
|
||||
function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
|
||||
return {
|
||||
...build,
|
||||
ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"),
|
||||
planCurves: [],
|
||||
cutWalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 방식 등록부 — 제안 ② 는 아래 주석 자리에 한 줄. */
|
||||
export const CORRIDOR_MODES: CorridorMode[] = [
|
||||
// 지금 — 비탈 리본 · 스케치 · 패치 · 지형은 띠만 자름(115-1 전 방식).
|
||||
{
|
||||
id: "current",
|
||||
corridor: (build, ctx) => {
|
||||
snapCorridorEdges(build, ctx);
|
||||
attachPatchSkirts(build, ctx);
|
||||
return build;
|
||||
},
|
||||
},
|
||||
// 옛 A(115-4) — 08-25 실루엣 갈아 끼우기 · 저장 안 함(브라우저 즉석 · 해시별 메모).
|
||||
{
|
||||
id: "legacy-a",
|
||||
slow: (build) => !legacyReady(build),
|
||||
corridor: (build, ctx) => {
|
||||
const legacy = legacyCorridorOf(build);
|
||||
if (legacy) snapCorridorEdges(legacy, ctx);
|
||||
return legacy;
|
||||
},
|
||||
},
|
||||
// 새 ①(115-1 · 115-5) — 노면부만 그리고 비탈은 지형 메시 변형 + 브레이크라인.
|
||||
{
|
||||
id: "new1",
|
||||
corridor: (build) => roadOnly(build),
|
||||
terrain: ({ split, build, bounds, heightIndex }) =>
|
||||
heightIndex ? deformTerrain(split, build, bounds, heightIndex) : null,
|
||||
},
|
||||
// 제안 ② — 다른 창이 여기 한 줄: { id: "proposal2", corridor: …, terrain: … },
|
||||
];
|
||||
|
||||
export function modeOf(id: CorridorModeId): CorridorMode | null {
|
||||
return CORRIDOR_MODES.find((mode) => mode.id === id) ?? null;
|
||||
}
|
||||
|
||||
const listeners = new Set<(id: CorridorModeId) => void>();
|
||||
|
||||
/** 고른 방식 — 저장값이 없으면 옛 두 단추 값을 옮겨 읽는다. */
|
||||
export function corridorModeId(): CorridorModeId {
|
||||
const saved = readStateRaw("corridor-mode");
|
||||
const known = MODE_SLOTS.find(([id]) => id === saved);
|
||||
if (known) return known[0];
|
||||
if (readStateRaw("corridor-legacy") === "1") return "legacy-a";
|
||||
if (readStateRaw("corridor-deform") === "0") return "current";
|
||||
return DEFAULT_MODE;
|
||||
}
|
||||
|
||||
/** 실제로 그릴 방식 — 고른 자리가 비었으면(제안 ② 미등록) 기본. */
|
||||
export function activeCorridorMode(): CorridorMode {
|
||||
return modeOf(corridorModeId()) ?? (modeOf(DEFAULT_MODE) as CorridorMode);
|
||||
}
|
||||
|
||||
export function setCorridorMode(id: CorridorModeId): void {
|
||||
writeStateRaw("corridor-mode", id);
|
||||
listeners.forEach((listener) => listener(id));
|
||||
}
|
||||
|
||||
export function onCorridorMode(listener: (id: CorridorModeId) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
import {
|
||||
corridorDeformOn,
|
||||
corridorLegacyOn,
|
||||
setCorridorDeform,
|
||||
setCorridorLegacy,
|
||||
} from "./B05_Profile_UI_Viewer_Corridor";
|
||||
corridorModeId,
|
||||
MODE_SLOTS,
|
||||
modeOf,
|
||||
onCorridorMode,
|
||||
setCorridorMode,
|
||||
} from "./B05_Profile_UI_Corridor_Modes";
|
||||
|
||||
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
|
||||
export interface ViewControlCallbacks {
|
||||
@@ -62,16 +63,34 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
return { kind, element };
|
||||
});
|
||||
viewButtons.append(...projectionButtons.map(({ element }) => element));
|
||||
// 3D 방식 고르기 한 줄(115-5) — 원근 · 직교와 같은 꼴. 등록 안 된 자리(제안 ②)는 꺼짐.
|
||||
const modeButtons = document.createElement("div");
|
||||
modeButtons.className = "b05-route__view-group";
|
||||
const modeLabel = document.createElement("span");
|
||||
modeLabel.className = "b05-route__view-label";
|
||||
modeLabel.textContent = "3D 방식";
|
||||
const modeChoices = MODE_SLOTS.map(([id, label]) => {
|
||||
const element = button(label, () => setCorridorMode(id), "glass");
|
||||
element.disabled = !modeOf(id);
|
||||
if (element.disabled) element.title = "아직 없는 방식입니다.";
|
||||
return { id, element };
|
||||
});
|
||||
const showMode = (): void => {
|
||||
const current = corridorModeId();
|
||||
modeChoices.forEach(({ id, element }) => {
|
||||
element.classList.toggle("is-active", id === current);
|
||||
element.setAttribute("aria-pressed", String(id === current));
|
||||
});
|
||||
};
|
||||
showMode();
|
||||
onCorridorMode(showMode);
|
||||
modeButtons.append(modeLabel, ...modeChoices.map(({ element }) => element));
|
||||
const visibilityButtons = document.createElement("div");
|
||||
visibilityButtons.className = "b05-route__view-group";
|
||||
visibilityButtons.append(
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
// 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치).
|
||||
toggleButton("새 방식", corridorDeformOn(), setCorridorDeform),
|
||||
// 옛 방식 A(115-4) — 08-25 실루엣 갈아 끼우기 · 켜면 새 방식 단추와 상관없이 A 형상.
|
||||
toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
@@ -102,9 +121,12 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
separator1.className = "b05-route__view-separator";
|
||||
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
||||
const separator3 = separator1.cloneNode() as HTMLSpanElement;
|
||||
const separator4 = separator1.cloneNode() as HTMLSpanElement;
|
||||
viewControls.append(
|
||||
viewButtons,
|
||||
separator1,
|
||||
modeButtons,
|
||||
separator4,
|
||||
visibilityButtons,
|
||||
separator2,
|
||||
button("뷰 초기화", callbacks.onResetView, "glass"),
|
||||
|
||||
@@ -205,6 +205,14 @@
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 3D 방식 고르기 한 줄 머리(115-5). */
|
||||
.b05-route__view-label {
|
||||
align-self: center;
|
||||
font-size: var(--font-size-12, 12px);
|
||||
color: var(--color-text-secondary, inherit);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b05-route__view-controls .ui-btn {
|
||||
height: 34px;
|
||||
padding: 0 var(--spacing-8);
|
||||
|
||||
@@ -17,53 +17,17 @@ import {
|
||||
import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip";
|
||||
import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug";
|
||||
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
|
||||
import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt";
|
||||
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
|
||||
import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy";
|
||||
import {
|
||||
activeCorridorMode,
|
||||
DEFAULT_MODE,
|
||||
MODE_SLOTS,
|
||||
modeOf,
|
||||
onCorridorMode,
|
||||
type CorridorMode,
|
||||
} from "./B05_Profile_UI_Corridor_Modes";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
|
||||
/**
|
||||
* 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치).
|
||||
* 사용자가 두 화면을 견주는 동안만 둔다(115-3 에서 옛 방식과 함께 지운다).
|
||||
*/
|
||||
const deformListeners = new Set<(on: boolean) => void>();
|
||||
|
||||
export function corridorDeformOn(): boolean {
|
||||
return readStateRaw("corridor-deform") !== "0";
|
||||
}
|
||||
|
||||
export function setCorridorDeform(on: boolean): void {
|
||||
writeStateRaw("corridor-deform", on ? "1" : "0");
|
||||
deformListeners.forEach((listener) => listener(on));
|
||||
}
|
||||
|
||||
/**
|
||||
* 옛 방식 A(115-4 · 08-25 실루엣 갈아 끼우기) — 켜면 「새 방식」 단추와 상관없이 A 형상.
|
||||
* 끄면 지금처럼(새 방식 켬/끔). 비교용 — 저장하지 않는다.
|
||||
*/
|
||||
const legacyListeners = new Set<(on: boolean) => void>();
|
||||
|
||||
export function corridorLegacyOn(): boolean {
|
||||
return readStateRaw("corridor-legacy") === "1";
|
||||
}
|
||||
|
||||
export function setCorridorLegacy(on: boolean): void {
|
||||
writeStateRaw("corridor-legacy", on ? "1" : "0");
|
||||
legacyListeners.forEach((listener) => listener(on));
|
||||
}
|
||||
|
||||
/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */
|
||||
function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
|
||||
return {
|
||||
...build,
|
||||
ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"),
|
||||
planCurves: [],
|
||||
cutWalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
|
||||
function countTriangles(root: THREE.Object3D | null): number {
|
||||
let total = 0;
|
||||
@@ -148,20 +112,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
let bandSplit: TerrainBandSplit | null = null;
|
||||
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
|
||||
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
|
||||
let deformOn = corridorDeformOn();
|
||||
const onDeform = (on: boolean): void => {
|
||||
deformOn = on;
|
||||
/** 지금 그리는 방식(115-5 고르기 한 줄)과 그 방식이 낸 코리도 빌드. */
|
||||
let mode: CorridorMode = activeCorridorMode();
|
||||
let drawnBuild: CorridorBuildResult | null = null;
|
||||
const offMode = onCorridorMode(() => {
|
||||
if (corridorBuild) set(corridorBuild);
|
||||
};
|
||||
deformListeners.add(onDeform);
|
||||
let legacyOn = corridorLegacyOn();
|
||||
/** 지금 그리고 있는 옛 방식 A 빌드 — 본체 빌드와 따로 둔 객체(심 보정이 제자리 수정). */
|
||||
let legacyBuild: CorridorBuildResult | null = null;
|
||||
const onLegacy = (on: boolean): void => {
|
||||
legacyOn = on;
|
||||
if (corridorBuild) set(corridorBuild);
|
||||
};
|
||||
legacyListeners.add(onLegacy);
|
||||
});
|
||||
|
||||
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
|
||||
function applyCorridorVisibility(): void {
|
||||
@@ -230,70 +186,6 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
corridorGroup = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지.
|
||||
* 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부
|
||||
* 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록).
|
||||
*/
|
||||
function snapCorridorEdges(build: CorridorBuildResult): void {
|
||||
const bounds = deps.bounds();
|
||||
if (!bounds) return;
|
||||
const index = deps.heightIndex();
|
||||
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) => {
|
||||
// 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시).
|
||||
// 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다.
|
||||
if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return;
|
||||
// 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체
|
||||
// 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그
|
||||
// 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m).
|
||||
if (ribbon.patch) return;
|
||||
const cols = ribbon.colCount;
|
||||
const outerCol = ribbon.side === "right" ? 0 : cols - 1;
|
||||
const innerCol = ribbon.side === "right" ? cols - 1 : 0;
|
||||
for (let row = 0; row < ribbon.chainages.length; row += 1) {
|
||||
const outer = (row * cols + outerCol) * 3;
|
||||
const inner = (row * cols + innerCol) * 3;
|
||||
// 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다.
|
||||
const width = Math.hypot(
|
||||
ribbon.positions[outer] - ribbon.positions[inner],
|
||||
ribbon.positions[outer + 1] - ribbon.positions[inner + 1],
|
||||
);
|
||||
if (width < 1e-4) continue;
|
||||
const height = index.heightAt(
|
||||
ribbon.positions[outer] - ox,
|
||||
-(ribbon.positions[outer + 1] - oy),
|
||||
);
|
||||
if (height !== null) ribbon.positions[outer + 2] = height + oz;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을
|
||||
* 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은
|
||||
* `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다.
|
||||
*/
|
||||
function attachPatchSkirts(build: CorridorBuildResult): void {
|
||||
const bounds = deps.bounds();
|
||||
if (!bounds) return;
|
||||
const index = deps.heightIndex();
|
||||
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;
|
||||
const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => {
|
||||
const height = index.heightAt(x - ox, -(y - oy));
|
||||
return height === null ? null : height + oz;
|
||||
});
|
||||
const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt);
|
||||
build.cutWalls = [...kept, ...walls];
|
||||
(window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats;
|
||||
}
|
||||
|
||||
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
|
||||
function scheduleTerrainClip(): void {
|
||||
disposeClippedTerrain();
|
||||
@@ -307,14 +199,21 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
const bounds = deps.bounds();
|
||||
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
|
||||
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
|
||||
const split = ensureBandSplit(legacyBuild ?? buildAtSchedule);
|
||||
const drawn = drawnBuild ?? buildAtSchedule;
|
||||
const split = ensureBandSplit(drawn);
|
||||
if (!split) return;
|
||||
// 옛 방식 A 는 지형을 비탈 끝 띠만 자른다(스케치 · 자른 자리 없음 → 원지반 되살림 없음).
|
||||
const index = deformOn && !legacyBuild ? deps.heightIndex() : null;
|
||||
const deformed = index ? deformTerrain(split, buildAtSchedule, bounds, index) : null;
|
||||
// 방식이 지형을 안 내면 그 방식 빌드로 띠만 자른다(지금 · 옛 A).
|
||||
const shaped =
|
||||
mode.terrain?.({
|
||||
build: buildAtSchedule,
|
||||
corridor: drawn,
|
||||
split,
|
||||
bounds,
|
||||
heightIndex: deps.heightIndex(),
|
||||
}) ?? null;
|
||||
(window as unknown as { __corridorDeform?: unknown }).__corridorDeform =
|
||||
deformed?.stats ?? null;
|
||||
const clipped = deformed?.root ?? clipTerrain(split, legacyBuild ?? buildAtSchedule, bounds);
|
||||
shaped?.stats ?? null;
|
||||
const clipped = shaped?.root ?? clipTerrain(split, drawn, bounds);
|
||||
disposeClippedTerrain();
|
||||
clippedTerrain = clipped;
|
||||
scene.add(clipped);
|
||||
@@ -332,34 +231,37 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
}
|
||||
|
||||
function set(build: CorridorBuildResult | null): void {
|
||||
// 옛 방식 A 는 처음 켤 때 즉석으로 만든다(몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다.
|
||||
if (build && legacyOn && !legacyReady(build)) {
|
||||
const picked = activeCorridorMode();
|
||||
// 처음 만들기가 무거운 방식(옛 A 즉석 빌드 · 몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다.
|
||||
if (build && picked.slow?.(build)) {
|
||||
corridorBuild = build;
|
||||
showToast("옛 방식 A(실루엣) 형상을 만드는 중입니다…", "info");
|
||||
const label = MODE_SLOTS.find(([id]) => id === picked.id)?.[1] ?? picked.id;
|
||||
showToast(`3D 방식 「${label}」 형상을 만드는 중입니다…`, "info");
|
||||
window.setTimeout(() => {
|
||||
if (corridorBuild !== build || !legacyOn) return;
|
||||
legacyCorridorOf(build);
|
||||
if (corridorBuild !== build || activeCorridorMode() !== picked) return;
|
||||
const bounds = deps.bounds();
|
||||
if (bounds) picked.corridor(build, { bounds, heightIndex: deps.heightIndex() });
|
||||
set(build);
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
disposeCorridorGroup();
|
||||
corridorBuild = build;
|
||||
legacyBuild = build && legacyOn ? legacyCorridorOf(build) : null;
|
||||
setCorridorBuildSummary(legacyBuild ?? build);
|
||||
mode = picked;
|
||||
drawnBuild = null;
|
||||
const bounds = deps.bounds();
|
||||
if (legacyBuild && bounds) {
|
||||
snapCorridorEdges(legacyBuild);
|
||||
corridorGroup = createCorridorGroup(legacyBuild, bounds);
|
||||
scene.add(corridorGroup);
|
||||
deps.onGroup();
|
||||
} else if (build && bounds) {
|
||||
// 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다.
|
||||
if (!deformOn) {
|
||||
snapCorridorEdges(build);
|
||||
attachPatchSkirts(build);
|
||||
if (build && bounds) {
|
||||
const ctx = { bounds, heightIndex: deps.heightIndex() };
|
||||
drawnBuild = mode.corridor(build, ctx);
|
||||
// 그 방식이 못 그리면(옛 A 입력 없음) 기본 방식으로.
|
||||
if (!drawnBuild) {
|
||||
mode = modeOf(DEFAULT_MODE) as CorridorMode;
|
||||
drawnBuild = mode.corridor(build, ctx);
|
||||
}
|
||||
corridorGroup = createCorridorGroup(deformOn ? roadOnly(build) : build, bounds);
|
||||
}
|
||||
setCorridorBuildSummary(drawnBuild ?? build);
|
||||
if (drawnBuild && bounds) {
|
||||
corridorGroup = createCorridorGroup(drawnBuild, bounds);
|
||||
scene.add(corridorGroup);
|
||||
deps.onGroup();
|
||||
}
|
||||
@@ -404,8 +306,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
|
||||
group: () => corridorGroup,
|
||||
clipped: () => clippedTerrain,
|
||||
dispose() {
|
||||
deformListeners.delete(onDeform);
|
||||
legacyListeners.delete(onLegacy);
|
||||
offMode();
|
||||
corridorBuild = null; // 예약된 클립 콜백 무효화.
|
||||
disposeCorridorGroup();
|
||||
disposeClippedTerrain();
|
||||
|
||||
Reference in New Issue
Block a user