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:
2026-10-08 22:23:54 +09:00
co-authored by Claude Opus 5.5
parent 8bbd9607b8
commit 378af8b171
13 changed files with 1564 additions and 243 deletions
+2
View File
@@ -70,6 +70,8 @@ export const STATE_REGISTRY = {
"corridor-deform": { bucket: "pref", scope: "global" },
/** B05 3D 옛 방식 A(실루엣 갈아 끼우기 · 115-4 비교용) — "1" 켬 · 그 밖 끔 */
"corridor-legacy": { bucket: "pref", scope: "global" },
/** B05 3D 방식 고르기(115-5) — current · legacy-a · new1(기본) · proposal2. 옛 두 키는 읽어서 옮김 */
"corridor-mode": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -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);
}
+31 -9
View File
@@ -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"),
+8
View File
@@ -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);
+48 -147
View File
@@ -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();
@@ -44,8 +44,8 @@ const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 1
const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5)
const ROAD_Z = 100;
/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */
function station(chainage, mode, groundZ, ratio) {
/** 직선 노선 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). 노선은 x 축에서 angle 만큼 돈다. */
function station(chainage, mode, groundZ, ratio, angle = 0) {
const w = Math.abs(groundZ - ROAD_Z) * ratio;
const pts = [
[-30, groundZ],
@@ -64,10 +64,10 @@ function station(chainage, mode, groundZ, ratio) {
chainage_m: chainage,
label: "",
kind: "regular",
center_x: chainage,
center_y: 0,
center_x: chainage * Math.cos(angle),
center_y: chainage * Math.sin(angle),
center_z: groundZ,
frame: { left_xy: [0, 1] },
frame: { left_xy: [-Math.sin(angle), Math.cos(angle)] },
samples,
design: {
section_mode: mode,
@@ -187,4 +187,221 @@ const results = {};
results.rowPlace = { alongMax, lateralMax, folds };
}
const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain"));
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
/** 격자 지형(모델 좌표 [x0,x1]×[y0,y1] · 칸 step) — 높이 함수 · 정점색 200. */
function gridTerrain(x0, x1, y0, y1, step, heightAt, bounds) {
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 nx = Math.round((x1 - x0) / step) + 1;
const ny = Math.round((y1 - y0) / step) + 1;
const positions = new Float32Array(nx * ny * 3);
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) {
const v = j * nx + i;
const x = x0 + i * step;
const y = y0 + j * step;
positions[v * 3] = x - cx;
positions[v * 3 + 1] = heightAt(x, y) - cz;
positions[v * 3 + 2] = -(y - cy);
}
}
const index = [];
for (let j = 0; j < ny - 1; j += 1) {
for (let i = 0; i < nx - 1; i += 1) {
const a = j * nx + i;
index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const colors = new Uint8Array(nx * ny * 4).fill(200);
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true));
geometry.setIndex(index);
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
}
function shape(build, bounds, terrain) {
const split = new TerrainBandSplit(terrain, { minX: -200, maxX: 200, minZ: -200, maxZ: 200 });
return deformTerrain(split, build, bounds, new TerrainHeightIndex(terrain));
}
/** 변형 지형 삼각형들 — 모델 좌표 [x,y,z]×3 · 정점 칠 표식. */
function trianglesOf(out, bounds) {
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 tris = [];
out.root.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
const p = child.geometry.getAttribute("position");
const idx = child.geometry.getIndex();
const keep = child.userData.keepColor;
for (let t = 0; t < idx.count; t += 3) {
const vs = [0, 1, 2].map((k) => idx.getX(t + k));
tris.push({
pts: vs.map((v) => [p.getX(v) + cx, -p.getZ(v) + cy, p.getY(v) + cz]),
keep: vs.map((v) => keep[v]),
});
}
});
return tris;
}
/** 점을 덮는 삼각형들의 높이 — 넓이 0 은 뺀다. */
function heightsAt(tris, x, y) {
const out = [];
for (const { pts } of tris) {
const [a, b, c] = pts;
const den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
if (Math.abs(den) < 1e-12) continue;
const w0 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / den;
const w1 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / den;
const w2 = 1 - w0 - w1;
if (w0 < -1e-7 || w1 < -1e-7 || w2 < -1e-7) continue;
out.push(w0 * a[2] + w1 * b[2] + w2 * c[2]);
}
return out;
}
// ④ 브레이크라인(P1-3) — 격자에 비스듬한 노선(31°) · 성토 1:1.5 · 울퉁불퉁 지형. 모든 정점에서
// 덮는 삼각형 높이가 같다(T자 균열 0) · 색은 면 단위 · 비탈 끝 선 위 높이 = 원지반.
{
const angle = (31 * Math.PI) / 180;
const sections = [0, 15, 30, 45].map((c) => station(c, "both_fill", 96, 1.5, angle));
const route = [
{ x: 0, y: 0 },
{ x: 45 * Math.cos(angle), y: 45 * Math.sin(angle) },
];
const build = buildCorridor(sections, route);
build.sections = sections;
const bounds = { x: [-40, 80], y: [-40, 70], z: [80, 110] };
const ground = (x, y) => 96 + 0.3 * Math.sin(x * 0.7) + 0.2 * Math.cos(y * 0.9);
const out = shape(build, bounds, gridTerrain(-20, 60, -20, 50, 0.97, ground, bounds));
const tris = trianglesOf(out, bounds);
let worst = 0;
let checked = 0;
const seen = new Set();
for (const { pts } of tris) {
for (const [x, y] of pts) {
const key = `${Math.round(x * 1e4)},${Math.round(y * 1e4)}`;
if (seen.has(key)) continue;
seen.add(key);
const hs = heightsAt(tris, x, y);
if (hs.length < 2) continue;
checked += 1;
worst = Math.max(worst, Math.max(...hs) - Math.min(...hs));
}
}
const mixed = tris.filter(({ keep }) => keep.some((k) => k) && keep.some((k) => !k)).length;
// 비탈 끝 선(가운데 행 좌측) 위 높이 = 원지반.
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), ground);
const row = Math.floor(field.rows.length / 2);
const [tx, ty] = field.pointAt(row, 0, field.catchAt(row, 0, 0));
const toe = heightsAt(tris, tx, ty);
results.breakline = {
lines: out.stats.lines,
split: out.stats.split,
fallback: out.stats.fallback,
checked,
worst,
mixed,
toeGap: toe.length ? Math.max(...toe.map((h) => Math.abs(h - ground(tx, ty)))) : null,
};
}
// ⑤ 구조물 둘레(P1-4) — 20 측점 좌측 옹벽(기초 5~7 · 높이 95.5 / 벽체 5~5.6 · 상단 99.5).
// 지형(100)이 벽 상단보다 높다. 구멍은 벽체 윗면만 · 기초 위 · 둘레 띠는 상단(99.5)까지 내림 ·
// 띠 밖은 그대로.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 100, 1.5));
const build = buildCorridor(sections, routeOf(sections));
build.sections = sections;
const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 });
build.structures = [
{
chainage_m: 20,
kind: "revet",
polygon: [
[5, 95],
[7, 95],
[7, 95.5],
[5.6, 95.5],
[5.6, 99.5],
[5, 99.5],
],
rings: [ring(18), ring(20), ring(22)],
},
];
const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] };
const out = shape(
build,
bounds,
gridTerrain(-10, 50, -20, 20, 0.5, () => 100, bounds),
);
const tris = trianglesOf(out, bounds);
const one = (x, y) => {
const hs = heightsAt(tris, x, y);
return hs.length ? Math.round(Math.max(...hs) * 1e4) / 1e4 : null;
};
results.drape = {
holes: out.stats.holes,
stemTop: one(20, 5.3), // 벽체 윗면 = 구멍
footing: one(20, 6.5), // 기초 위 = 묻음 · 상단까지
band: one(20, 7.4), // 둘레 띠(0.75 m) 안
outside: one(20, 9), // 띠 밖 = 그대로
endBand: one(22.5, 5.3), // 구조물 끝 너머 띠
};
}
// ⑥ 3D 방식 등록부(고르기 한 줄) — 자리 넷 · 등록 셋 · 제안 ② 빈자리 · 옛 두 키 옮겨 읽기.
{
const store = () => {
const proto = {
getItem(k) {
return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : null;
},
setItem(k, v) {
this[k] = String(v);
},
removeItem(k) {
delete this[k];
},
key(i) {
return Object.keys(this)[i] ?? null;
},
};
Object.defineProperty(proto, "length", {
get() {
return Object.keys(this).length;
},
});
return Object.create(proto);
};
globalThis.window = { localStorage: store(), sessionStorage: store() };
const modes = loadTs(B05("B05_Profile_UI_Corridor_Modes"));
const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const picks = {};
picks.fresh = modes.corridorModeId();
state.writeStateRaw("corridor-deform", "0");
picks.oldDeformOff = modes.corridorModeId();
state.writeStateRaw("corridor-legacy", "1");
picks.oldLegacyOn = modes.corridorModeId();
modes.setCorridorMode("proposal2");
picks.proposal = modes.corridorModeId();
picks.proposalDraws = modes.activeCorridorMode().id;
modes.setCorridorMode("new1");
picks.set = modes.corridorModeId();
results.modes = {
slots: modes.MODE_SLOTS.map(([id, label]) => `${id}:${label}`),
registered: modes.CORRIDOR_MODES.map((mode) => mode.id),
defaultMode: modes.DEFAULT_MODE,
newHasTerrain: typeof modes.modeOf("new1").terrain === "function",
...picks,
};
}
process.stdout.write(JSON.stringify(results));
@@ -62,6 +62,7 @@ def test_legacy_state_key_registered():
registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry
assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry
panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8")
assert 'toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy)' in panel
assert 'toggleButton("새 방식", corridorDeformOn(), setCorridorDeform)' in panel
# 115-5 — 단추 둘은 고르기 한 줄 「3D 방식」로 옮겼다(옛 키 값은 읽어서 옮김).
modes = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts").read_text(encoding="utf-8")
assert '["legacy-a", "옛 A"]' in modes
assert 'readStateRaw("corridor-legacy") === "1"' in modes
@@ -2,12 +2,14 @@
"""PLAN 115-5 — B05 3D 제안 ① 새 방식 개선 합성 검사.
Node 도우미(helper_115_5_corridor_improve.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m.
결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m · 브레이크라인 T자 균열 0 ·
구조물 둘레(윗면 구멍 · 드레이프) · 3D 방식 등록부(고르기 한 줄).
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
@@ -44,3 +46,43 @@ def test_row_place_follows_route_length(results):
assert r["alongMax"] < 1e-6 # 행 자리 = 누가거리(0 m)
assert r["lateralMax"] < 1e-6 # 노선 위
assert r["folds"] == 0
def test_breakline_no_t_cracks_and_face_colours(results):
r = results["breakline"]
assert r["split"] > 0 and r["fallback"] == 0
assert r["checked"] > 1000
assert r["worst"] < 1e-4 # 같은 자리 덮는 삼각형 높이 같음(T자 균열 0)
assert r["mixed"] == 0 # 색은 면 단위 — 한 삼각형에 칠 · 원지반 섞임 없음
assert r["toeGap"] < 0.02 # 비탈 끝 선 위 = 원지반
def test_structure_top_hole_and_drape(results):
r = results["drape"]
assert r["holes"] == 1
assert r["stemTop"] is None # 벽체 윗면만 구멍
assert r["footing"] == 99.5 # 기초 위는 묻고 벽 상단까지 내림
assert r["band"] == 99.5 # 둘레 띠 안
assert r["endBand"] == 99.5 # 끝 너머 띠
assert r["outside"] == 100 # 띠 밖은 그대로
def test_mode_registry_and_migration(results):
r = results["modes"]
assert r["slots"] == ["current:지금", "legacy-a:옛 A", "new1:새 ①", "proposal2:제안 ②"]
assert r["registered"] == ["current", "legacy-a", "new1"] # 제안 ② 는 다른 창 몫(빈자리)
assert r["defaultMode"] == "new1" and r["fresh"] == "new1"
assert r["newHasTerrain"] is True
assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금
assert r["oldLegacyOn"] == "legacy-a" # 옛 「옛 방식 A」 켬 → 옛 A
assert r["proposal"] == "proposal2" and r["proposalDraws"] == "new1" # 미등록 → 기본으로 그림
assert r["set"] == "new1"
def test_mode_row_replaces_old_toggles():
root = Path(HERE).parents[1]
registry = (root / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8")
assert '"corridor-mode": { bucket: "pref", scope: "global" }' in registry
panel = (root / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8")
assert "MODE_SLOTS.map" in panel and '"3D 방식"' in panel
assert 'toggleButton("새 방식"' not in panel and "옛 방식 A(실루엣)" not in panel
@@ -22,7 +22,8 @@ if str(PROJECT_ROOT) not in sys.path:
# 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리).
CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts"
# 지형 스냅 가드는 뷰어에서 코리도 조각으로 옮겼다(계획서 24-3a · 700줄 나눔).
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer_Corridor.ts"
# 115-5 — 심 보정(snapCorridorEdges)은 방식 등록부로 옮겼다.
VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts"
CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts"
CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts"