feat(B05): 3D 제안 ③ 사면 모양 평활화 — 사면 격자(노선 1 m × 빗면 약 1 m · 꺾임은 격자선) · Taubin 면 평활화 · 비탈 끝 선 가우스(측점 고정) · 곡선 안쪽 포락선(엇갈린 사면 선은 먼 쪽이 만나는 선에서 멈춤) · 곡률 상한(115-10)

- 새 _Corridor_Loft_Toe.ts(비탈 끝 거리 고르기 · 측점 고정 · 포락선) · _Corridor_Loft_Grid.ts(Taubin)
- _Corridor_Loft.ts 행마다 점 둘 부채꼴 → 격자 · 설계 점 0.3 m 솎기 · 둘레는 지형에 닿은 비탈 끝만
- 사면 실오라기 67 → 1.5 % · 음영 단차 p90 5.6 → 0.02° · 측점 오차 그대로(226 · d22)
- test_115_10 새로 · test_115_8 직선 · 꼬임 단언을 격자 · 포락선에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-09 10:23:35 +09:00
co-authored by Claude Opus 5.5
parent 262f6d387e
commit 0880d2b316
6 changed files with 778 additions and 205 deletions
+350 -198
View File
@@ -1,15 +1,16 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Loft.ts
* 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 사용자 안) — 비탈을 따로 그려 넣는다.
* 3D 제안 ③ **측점 횡단 기반 사면**(계획서 115-8 · 115-9 · 115-10 · 사용자 안) — 비탈을 따로 그려 넣는다.
*
* ① 측점 단면 = B06 설계선 비탈 꼭짓점(`_Loft_Profile.ts`) · 비탈 끝 = 평면도 외곽선과 같은 규칙.
* ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 직선 보간(바깥거리 ·
* 높이 둘 다) → 비탈 끝 선 = 평면도 외곽선(비탈 끝 오프셋 노선 따라 보간). 성토 ↔ 절토가
* 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양, 끝 오프셋만 보간).
* ② 측점 사이 = 이웃 단면을 같은 점 수로 짝 맞춰(꺾임끼리) 누가거리로 단조 3차 보간(바깥거리 · 높이).
* 성토 ↔ 절토가 바뀌면 평면도처럼 가운데서 끊는다(앞 반 = 앞 측점 모양 · 뒤 반 = 뒤 측점 모양).
* ① · ② 처럼 행마다 지형과 다시 맞추지 않는다.
* ③ 드레이프 = 비탈 끝 높이를 그 자리 지형에 맞추고 보정량을 노면부 끝 0 → 비탈 끝 1 로 퍼뜨림.
* ④ 곡선 안쪽 겹침 = 앞 행 단면에 닿으면 그 자리에서 자름(제안 ② 와 같은 청소).
* ③ 비탈 끝 선 = 평면에서 고르고 곡선 안쪽 꼬임은 포락선으로(`_Loft_Toe.ts`) — 원뿔 · 톱니 없음.
* ④ 사면 격자 = 노선 1 m × 빗면 약 1 m(꺾임은 격자선) · 높이 = 단면 보간 + 드레이프(비탈 끝 높이를
* 지형에 맞춘 보정량을 노견 끝 0 → 비탈 끝 1) · Taubin 면 평활화(`_Loft_Grid.ts`) → 실오라기 없음.
* 지형 이음 · 구조물 구멍 · 구역 색 · 망 = 제안 ② 기계(`_Tin.ts` tinTerrain) 그대로 — 설계 점 원천만 바꿈.
* 음영 = 사면만 꺾임 법선(`_Loft_Shade.ts`).
* ========================================================================== */
import type * as THREE from "three";
@@ -30,6 +31,7 @@ import {
EDGE_ANCHOR,
EDGE_DESIGN,
EDGE_HOLE,
LoopIndex,
Pslg,
SegGrid,
TAG_ANCHOR,
@@ -47,14 +49,22 @@ import {
stationLofts,
} from "./B05_Profile_UI_Corridor_Loft_Profile";
import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade";
import { gauss, smoothToe } from "./B05_Profile_UI_Corridor_Loft_Toe";
import { taubin, type GridRow } from "./B05_Profile_UI_Corridor_Loft_Grid";
/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */
const MIN_CATCH_M = 0.02;
/** 종방향 선 — 사이 행 비탈 끝이 이만큼 바깥에 있어야 잇는다(m). */
const LINE_GAP_M = 0.05;
// ponytail: 드레이프 보정량 고르기 폭 ±2 m(행 0.2 m → 약 21 행 평균) — 지형 잡음 출렁임만 누름 · 비탈 끝
// 점은 제 행 보정 그대로(지형 위 · 틈 0) · 측점 행은 B06 그대로라 고르지 않음. 넓히면 비탈 끝 바로 안 띠가 꺾임.
const DRAPE_SMOOTH_M = 2;
// ponytail: 이보다 짧은 비탈은 없는 것으로(10 cm) — 부채꼴 끝 0.2 m 행 사이 실오라기 삼각형을 막음.
const MIN_SLOPE_M = 0.1;
// ponytail: 사면 방향 격자 간격 1 m(빗면 길이) — 노선 방향 격자 행(1 m)과 맞춤 · 줄이면 점 수 제곱으로 늚.
const CELL_M = 1;
// ponytail: 곡선 안쪽 비탈 끝 거리 ≤ (곡률 반경 − 반폭) × 0.8 · 곡률은 ±2 m 가우스 — 비탈 끝 선이 곡률 중심을
// 넘어 뒤집히는(원뿔) 자리를 막음 · 그만큼 비탈이 가팔라짐(드레이프가 지형에 맞춤).
const INNER_REACH_SHARE = 0.8;
const CURVE_SIGMA_M = 6;
// ponytail: 설계 점 사이 최소 0.3 m — 곡선 안쪽에서 몰리는 격자 · 비탈 끝 점을 솎아 실오라기를 막음 ·
// 늘리면 곡선 안쪽 사면이 성겨짐.
const POINT_GAP_M = 0.3;
/** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */
const HOLE_CLEAR_M = 0.3;
/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */
const HOLE_REACH_M = 80;
@@ -66,12 +76,23 @@ interface View {
pts: Float64Array;
}
/** 드레이프한 행 단면 — 바깥거리 · 절대 높이(모델). */
interface Slope {
s: Float64Array;
z: Float64Array;
/** 단면 보기의 바깥거리 s 자리 높이 차(선형). */
function dzAt(pts: Float64Array, s: number): number {
const m = pts.length / 2;
if (s <= pts[0]) return pts[1];
for (let k = 1; k < m; k += 1) {
if (s <= pts[k * 2]) {
const span = pts[k * 2] - pts[k * 2 - 2];
const t = span <= 1e-12 ? 1 : (s - pts[k * 2 - 2]) / span;
return pts[k * 2 - 1] + (pts[k * 2 + 1] - pts[k * 2 - 1]) * t;
}
}
return pts[m * 2 - 1];
}
/** 격자 행의 (보기 key, 단면 꼭짓점 번째) → 격자 점 번째. */
type VertexIndex = Map<number, Map<number, number>>;
/** 제안 ③ 설계 점 · 브레이크라인 · 구멍 — 제안 ② 와 같은 꼴(TinDesign). */
export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
const rows = roadRows(build);
@@ -86,9 +107,17 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
const sx = (mx: number): number => mx - ox;
const sz = (my: number): number => -(my - oy);
const pslg = new Pslg();
const stats = { rows: n, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 };
const stats = {
rows: n,
sections: 0,
bowtie: 0,
bendLines: 0,
holes: 0,
splits: 0,
gridPoints: 0,
};
// 행 틀 — 좌향 단위 벡터 · 반폭 · 중심.
// 행 틀 — 좌향 단위 벡터 · 반폭.
const ux = new Float64Array(n);
const uy = new Float64Array(n);
const hw = new Float64Array(n);
@@ -101,11 +130,27 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
hw[r] = width / 2;
});
const anchorOf = (r: number, si: number) => (si === 0 ? rows[r].left : rows[r].right);
const pointAt = (r: number, si: number, s: number): [number, number] => {
const anchor = anchorOf(r, si);
const sign = si === 0 ? 1 : -1;
return [anchor[0] + ux[r] * sign * s, anchor[1] + uy[r] * sign * s];
};
// 곡률(법선 회전 / 누가거리 · ±2 m 고름) — 양수 = 좌회전(좌측이 곡선 안쪽).
const chainages = rows.map((row) => row.chainage_m);
const bend = new Float64Array(n);
for (let r = 0; r < n; r += 1) {
const a = Math.max(0, r - 1);
const b = Math.min(n - 1, r + 1);
let turn = Math.atan2(uy[b], ux[b]) - Math.atan2(uy[a], ux[a]);
if (turn > Math.PI) turn -= 2 * Math.PI;
if (turn < -Math.PI) turn += 2 * Math.PI;
const run = chainages[b] - chainages[a];
bend[r] = run > 1e-9 ? turn / run : 0;
}
const curvature = gauss(chainages, bend, CURVE_SIGMA_M);
/** 곡선 안쪽 비탈 끝 거리 상한 — 노면 끝에서 곡률 중심까지의 몫(바깥쪽 · 직선은 무한). */
const maxReachOf = (si: number): Float64Array =>
Float64Array.from(curvature, (k, r) => {
const inner = si === 0 ? k > 0 : k < 0;
return inner && Math.abs(k) > 1e-9
? Math.max(0, (1 / Math.abs(k) - hw[r]) * INNER_REACH_SHARE)
: Infinity;
});
/** 누가거리를 감싼 측점 마디 [i, t] — 범위 밖은 끝 마디. */
const bracket = (c: number): [number, number] => {
@@ -195,38 +240,6 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
for (let k = 2; k < pts.length; k += 2) pts[k] = Math.max(pts[k], pts[k - 2]);
return { key: i * 2, kind, pts };
};
/** 고른 드레이프 보정량(행 · 측) — NaN 이면 제 행 값. 아래 비탈 끝 앞에서 채운다. */
const smoothLift = [0, 1].map(() => new Float64Array(n).fill(NaN));
/** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 s / 끝 비율(안쪽은 고른 값). */
const drape = (r: number, si: number, v: View): Slope | null => {
const m = v.pts.length / 2;
const end = v.pts[(m - 1) * 2];
if (end < MIN_CATCH_M) return null;
const anchor = anchorOf(r, si);
const [ex, ey] = pointAt(r, si, end);
const ground = groundAt(ex, ey);
const delta = ground === null ? 0 : ground - (anchor[2] + v.pts[(m - 1) * 2 + 1]);
const s = new Float64Array(m);
const z = new Float64Array(m);
for (let k = 0; k < m; k += 1) {
s[k] = v.pts[k * 2];
// 고른 몫은 s 비율 · 제 행에 남는 몫(지형 잡음)은 (s / 끝)² — 끝 점은 제 행 값(지형 위) 그대로.
const smooth = Number.isNaN(smoothLift[si][r]) ? delta : smoothLift[si][r];
const f = s[k] / end;
z[k] = anchor[2] + v.pts[k * 2 + 1] + smooth * f + (delta - smooth) * f * f;
}
return { s, z };
};
const zAt = (slope: Slope, s: number): number => {
for (let k = 1; k < slope.s.length; k += 1) {
if (s <= slope.s[k] || k === slope.s.length - 1) {
const span = slope.s[k] - slope.s[k - 1];
const t = span <= 1e-12 ? 1 : Math.min(1, (s - slope.s[k - 1]) / span);
return slope.z[k - 1] + (slope.z[k] - slope.z[k - 1]) * t;
}
}
return slope.z[0];
};
/** 성토 금지 고리 안은 원지반(또는 천장) 위로 못 올린다(제안 ② designZ 와 같은 규칙). */
const limited = (x: number, y: number, z: number): number => {
const ground = groundAt(x, y);
@@ -238,6 +251,23 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
return z;
};
const tagOf = (kind: number): number => (kind === 0 ? TAG_FILL : TAG_CUT);
const stationAt = (c: number): number =>
stations.findIndex((station) => Math.abs(station.chainage_m - c) < 1e-3);
// 구조물 윗면 고리(모델) — 격자 점을 그 안 · 둘레 가까이엔 두지 않는다.
const topLoops = structureTopLoops(build.structures ?? []);
const holeIndex = new LoopIndex(
topLoops.map((loop) => loop.map(([x, y]): [number, number] => [x, y])),
);
const holeEdges = new SegGrid(2);
topLoops.forEach((loop, l) =>
loop.forEach(([x0, y0], i) => {
const [x1, y1] = loop[(i + 1) % loop.length];
holeEdges.add(l, x0, y0, x1, y1);
}),
);
const nearHole = (x: number, y: number): boolean =>
holeIndex.contains(x, y) || holeEdges.passesNear(x, y, HOLE_CLEAR_M);
// 노면 끝 선 — 리본 바깥 끝 그대로(제안 ② 와 같음).
const anchors = [0, 1].map((si) =>
@@ -250,93 +280,70 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
pslg.addEdge(anchors[0][0], anchors[1][0], EDGE_ANCHOR);
pslg.addEdge(anchors[0][n - 1], anchors[1][n - 1], EDGE_ANCHOR);
// 드레이프 보정량 고르기 — 측점 아닌 행만 · 비탈 있는 행끼리 ±2 m 평균.
const stationRow = (r: number): boolean =>
stations.some((station) => Math.abs(station.chainage_m - rows[r].chainage_m) < 1e-3);
[0, 1].forEach((si) => {
const raw = new Float64Array(n).fill(NaN);
for (let r = 0; r < n; r += 1) {
const [i, t] = bracket(rows[r].chainage_m);
const v = view(i, t, si);
const slope = v && drape(r, si, v);
if (!slope) continue;
const m = slope.s.length;
raw[r] = slope.z[m - 1] - (anchorOf(r, si)[2] + (v as View).pts[(m - 1) * 2 + 1]);
}
let lo = 0;
let hi = 0;
let sum = 0;
let count = 0;
for (let r = 0; r < n; r += 1) {
const c = rows[r].chainage_m;
while (hi < n && rows[hi].chainage_m <= c + DRAPE_SMOOTH_M) {
if (!Number.isNaN(raw[hi])) ((sum += raw[hi]), (count += 1));
hi += 1;
}
while (rows[lo].chainage_m < c - DRAPE_SMOOTH_M) {
if (!Number.isNaN(raw[lo])) ((sum -= raw[lo]), (count -= 1));
lo += 1;
}
if (!Number.isNaN(raw[r]) && !stationRow(r) && count) smoothLift[si][r] = sum / count;
}
});
// 비탈 끝 — 행마다(평면도 외곽선과 같은 보간) · 곡선 안쪽 겹침은 앞 행 단면에 닿는 자리에서 자름.
// 행마다 단면 보기 · 비탈 끝(평면 고르기 · 포락선) · 드레이프 보정량.
const views = [0, 1].map((si) =>
rows.map((row) => {
const [i, t] = bracket(row.chainage_m);
return view(i, t, si);
}),
);
const keyOf = [0, 1].map(() => new Int32Array(n).fill(-1));
/** 비탈 끝(지형에 닿는 끝 · 고른 거리) 평면 자리 · 거리 · 보이는 몫 · 드레이프 보정량. */
const toeX = [0, 1].map(() => new Float64Array(n));
const toeY = [0, 1].map(() => new Float64Array(n));
const reachOf = [0, 1].map(() => new Float64Array(n));
const daylight = [0, 1].map(() => new Int32Array(n));
const shownOf = [0, 1].map(() => new Float64Array(n));
const liftOf = [0, 1].map(() => new Float64Array(n));
[0, 1].forEach((si) => {
const fan = new SegGrid(2);
for (let r = 0; r < n; r += 1) {
daylight[si][r] = anchors[si][r];
const [i, t] = bracket(rows[r].chainage_m);
const v = view(i, t, si);
const slope = v && drape(r, si, v);
if (!v || !slope) continue;
const end = slope.s[slope.s.length - 1];
const ax = pslg.x[anchors[si][r]];
const az = pslg.z[anchors[si][r]];
const [dx, dy] = pointAt(r, si, end);
const hit = fan.firstHit(ax, az, sx(dx), sz(dy));
let reach = end;
if (hit < 1) {
reach *= hit;
stats.bowtie += 1;
if (reach < MIN_CATCH_M) continue;
const sign = si === 0 ? 1 : -1;
const has = new Uint8Array(n);
const reach = new Float64Array(n);
views[si].forEach((v, r) => {
const end = v ? endOf(v) : 0;
if (v && end >= MIN_SLOPE_M) {
has[r] = 1;
reach[r] = end;
}
const [px, py] = pointAt(r, si, reach);
const z = limited(px, py, zAt(slope, reach));
daylight[si][r] = pslg.addPoint(sx(px), sz(py), z - oz, tagOf(v.kind));
});
const toe = smoothToe({
chainage: chainages,
ax: rows.map((_, r) => anchorOf(r, si)[0]),
ay: rows.map((_, r) => anchorOf(r, si)[1]),
nx: Array.from(ux, (v) => v * sign),
ny: Array.from(uy, (v) => v * sign),
reach,
has,
maxReach: maxReachOf(si),
pinned: Uint8Array.from(rows, (row) => (stationAt(row.chainage_m) >= 0 ? 1 : 0)),
});
stats.bowtie += toe.folded;
for (let r = 0; r < n; r += 1) {
const v = views[si][r];
const e = toe.reach[r];
if (!v || !has[r] || e * toe.shown[r] < MIN_SLOPE_M) continue;
const [ax, ay, az] = anchorOf(r, si);
toeX[si][r] = ax + ux[r] * sign * e;
toeY[si][r] = ay + uy[r] * sign * e;
const ground = groundAt(toeX[si][r], toeY[si][r]);
liftOf[si][r] = ground === null ? 0 : ground - (az + v.pts[v.pts.length - 1]);
reachOf[si][r] = e;
shownOf[si][r] = toe.shown[r];
keyOf[si][r] = v.key;
reachOf[si][r] = reach;
fan.add(r, ax, az, sx(px), sz(py));
}
});
/** 행 · 측 사면 비율 u 자리 — 평면(노면 끝 → 비탈 끝 직선) · 높이(단면 + 드레이프). */
const placeAt = (r: number, si: number, v: View, u: number): [number, number, number] => {
const [ax, ay, az] = anchorOf(r, si);
return [
ax + (toeX[si][r] - ax) * u,
ay + (toeY[si][r] - ay) * u,
az + dzAt(v.pts, u * endOf(v)) + liftOf[si][r] * u,
];
};
/** 비탈 끝까지 닿은 행(지형 위 끝 · 둘레) — 포락선에서 멈춘 행은 안쪽 골 · 마루 점. */
const daylit = (si: number, r: number): boolean => shownOf[si][r] >= 1 - 1e-9;
// 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리.
const left = Array.from(daylight[0]);
const right = Array.from(daylight[1]);
pslg.addPath(left, EDGE_DESIGN);
pslg.addPath(right, EDGE_DESIGN);
pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN);
pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN);
pslg.addPath([anchors[0][n - 1], left[n - 1]], EDGE_DESIGN);
pslg.addPath([anchors[1][n - 1], right[n - 1]], EDGE_DESIGN);
const loopIds = [
anchors[0][0],
...left,
anchors[0][n - 1],
anchors[1][n - 1],
...right.reverse(),
anchors[1][0],
];
const outer = loopIds
.filter((id, i) => i === 0 || id !== loopIds[i - 1])
.map((id): [number, number] => [pslg.x[id], pslg.z[id]]);
// 단면 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤.
const stationAt = (c: number): number =>
stations.findIndex((station) => Math.abs(station.chainage_m - c) < 1e-3);
// 격자 행 — 처음 · 끝 · 측점 · 1 m 걸음 · 단면 보기가 바뀌는 자리(성토 ↔ 절토 끊음) 앞뒤.
const mark = new Uint8Array(n);
let previous = -Infinity;
for (let r = 0; r < n; r += 1) {
@@ -354,69 +361,215 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
previous = c;
}
}
mark.forEach((m) => (stats.sections += m));
// 횡방향 선(노면 끝 → 꼭짓점 → 비탈 끝) · 같은 보기의 같은 번째 꼭짓점을 잇는 종방향 선.
// 설계 점 솎기 — 이미 둔 설계 점에서 POINT_GAP_M 안이면 새 점을 두지 않는다(실오라기 막음).
const placed = new Map<number, number[]>();
const cellOf = (x: number, y: number): number =>
Math.floor(x / POINT_GAP_M) * 1_000_003 + Math.floor(y / POINT_GAP_M);
const crowded = (x: number, y: number): boolean => {
const cx = Math.floor(x / POINT_GAP_M);
const cy = Math.floor(y / POINT_GAP_M);
for (let i = -1; i <= 1; i += 1) {
for (let j = -1; j <= 1; j += 1) {
const list = placed.get((cx + i) * 1_000_003 + cy + j);
if (!list) continue;
for (let k = 0; k < list.length; k += 2) {
if (Math.hypot(list[k] - x, list[k + 1] - y) < POINT_GAP_M) return true;
}
}
}
return false;
};
const place = (x: number, y: number, z: number, tag: number): number => {
const key = cellOf(x, y);
const list = placed.get(key) ?? [];
list.push(x, y);
placed.set(key, list);
return pslg.addPoint(sx(x), sz(y), z - oz, tag);
};
// 비탈 끝 점 — 닿은 행은 지형 위(둘레) · 멈춘 행은 골 · 마루 점(안쪽). 격자 행은 늘 두고 나머지는 솎음.
const daylight = [0, 1].map(() => new Int32Array(n).fill(-1));
[0, 1].forEach((si) => {
let before: { r: number; ids: Map<number, { s: Float64Array; id: Int32Array }> } | null = null;
for (let r = 0; r < n; r += 1) {
if (!mark[r]) continue;
stats.sections += si === 0 ? 1 : 0;
const reach = reachOf[si][r];
if (keyOf[si][r] < 0) {
before = null;
const v = views[si][r];
if (keyOf[si][r] < 0 || !v) continue;
const [x, y, line] = placeAt(r, si, v, shownOf[si][r]);
// 격자 행 비탈 끝은 남김 · 포락선에서 멈춘 점(골 · 마루)은 격자 행이라도 몰리면 솎음.
if ((!mark[r] || !daylit(si, r)) && crowded(x, y)) continue;
const ground = daylit(si, r) ? groundAt(x, y) : null;
daylight[si][r] = place(x, y, limited(x, y, ground ?? line), tagOf(v.kind));
}
});
// 설계 구역 둘레 — 좌 비탈 끝(오름) → 끝 마구리 → 우 비탈 끝(내림) → 시작 마구리. 비탈 없는 행은
// 노면 끝 · 멈춘 행(포락선 안쪽)은 둘레에서 뺀다.
const rim = (si: number): number[] => {
const ids: number[] = [];
for (let r = 0; r < n; r += 1) {
if (keyOf[si][r] < 0) ids.push(anchors[si][r]);
else if (daylit(si, r) && daylight[si][r] >= 0) ids.push(daylight[si][r]);
}
return ids.filter((id, i) => i === 0 || id !== ids[i - 1]);
};
const left = rim(0);
const right = rim(1);
pslg.addPath(left, EDGE_DESIGN);
pslg.addPath(right, EDGE_DESIGN);
pslg.addPath([anchors[0][0], left[0]], EDGE_DESIGN);
pslg.addPath([anchors[1][0], right[0]], EDGE_DESIGN);
pslg.addPath([anchors[0][n - 1], left[left.length - 1]], EDGE_DESIGN);
pslg.addPath([anchors[1][n - 1], right[right.length - 1]], EDGE_DESIGN);
const loopIds = [
anchors[0][0],
...left,
anchors[0][n - 1],
anchors[1][n - 1],
...right.reverse(),
anchors[1][0],
];
const outer = loopIds
.filter((id, i) => i === 0 || id !== loopIds[i - 1])
.map((id): [number, number] => [pslg.x[id], pslg.z[id]]);
/** 격자 행 하나 — 단면 꼭짓점(고정) 사이를 빗면 약 1 m 로 나눈 u · 높이. 측점 행은 다 고정. */
const gridRowOf = (r: number, si: number, v: View): { row: GridRow; index: VertexIndex } => {
const j = stationAt(rows[r].chainage_m);
const station = j >= 0;
const top = shownOf[si][r];
// 측점 행은 앞뒤 두 짝 맞춤의 꼭짓점을 다 담는다(둘 다 B06 단면 위).
const spots: Array<[number, number]> = station
? [
...(j > 0 ? [[j - 1, 1] as [number, number]] : []),
...(j < last || !last ? [[j, 0] as [number, number]] : []),
]
: [bracket(rows[r].chainage_m)];
const keys: Array<{ u: number; key: number; k: number }> = [];
for (const [i, t] of spots) {
const w = view(i, t, si);
if (!w) continue;
const end = endOf(w);
for (let k = 0; k < w.pts.length / 2; k += 1) {
const u = end > 1e-9 ? w.pts[k * 2] / end : k ? 1 : 0;
if (u < top - 1e-6) keys.push({ u, key: w.key, k });
}
}
keys.push({ u: top, key: -1, k: -1 });
keys.sort((a, b) => a.u - b.u);
const us: number[] = [];
const fixed: number[] = [];
const index: VertexIndex = new Map();
const note = (key: number, k: number, at: number): void => {
if (key < 0) return;
const map = index.get(key) ?? new Map<number, number>();
map.set(k, at);
index.set(key, map);
};
const end = endOf(v);
for (const item of keys) {
const u = Math.min(top, Math.max(0, item.u));
const at = us.length - 1;
if (at >= 0 && u - us[at] < 1e-6) {
note(item.key, item.k, at);
continue;
}
const j = stationAt(rows[r].chainage_m);
const spots: Array<[number, number]> =
j >= 0
? [
...(j > 0 ? [[j - 1, 1] as [number, number]] : []),
...(j < last || !last ? [[j, 0] as [number, number]] : []),
]
: [bracket(rows[r].chainage_m)];
const ids = new Map<number, { s: Float64Array; id: Int32Array }>();
const path: Array<[number, number]> = [];
for (const [i, t] of spots) {
const v = view(i, t, si);
const slope = v && drape(r, si, v);
if (!v || !slope) continue;
const m = slope.s.length;
const id = new Int32Array(m).fill(-1);
for (let k = 1; k < m - 1; k += 1) {
if (slope.s[k] >= reach - LINE_GAP_M) break;
const [x, y] = pointAt(r, si, slope.s[k]);
id[k] = pslg.addPoint(sx(x), sz(y), limited(x, y, slope.z[k]) - oz, tagOf(v.kind));
path.push([slope.s[k], id[k]]);
if (at >= 0) {
const plan = (u - us[at]) * reachOf[si][r];
const rise = dzAt(v.pts, u * end) - dzAt(v.pts, us[at] * end);
const pieces = Math.ceil(Math.hypot(plan, rise) / CELL_M);
for (let p = 1; p < pieces; p += 1) {
us.push(us[at] + ((u - us[at]) * p) / pieces);
fixed.push(station ? 1 : 0);
}
ids.set(v.key, { s: slope.s, id });
}
path.sort((a, b) => a[0] - b[0]);
const line = [anchors[si][r], ...path.map(([, id]) => id), daylight[si][r]];
pslg.addPath(
line.filter((id, k) => k === 0 || id !== line[k - 1]),
EDGE_DESIGN,
);
if (before) {
for (const [key, now] of ids) {
const then = before.ids.get(key);
if (!then || then.id.length !== now.id.length) continue;
for (let k = 1; k < now.id.length - 1; k += 1) {
if (then.id[k] < 0 || now.id[k] < 0) continue;
// 사이 행 비탈 끝이 그 꼭짓점보다 안쪽이면 잇지 않는다(선이 설계 구역 밖으로 나감).
let inside = true;
for (let q = before.r + 1; q < r && inside; q += 1) {
const f = (q - before.r) / (r - before.r);
const s = then.s[k] + (now.s[k] - then.s[k]) * f;
if (keyOf[si][q] < 0 || reachOf[si][q] < s + LINE_GAP_M) inside = false;
}
if (!inside) continue;
pslg.addEdge(then.id[k], now.id[k], EDGE_DESIGN);
// 끝(비탈 끝 · 멈춘 자리) 바로 앞 POINT_GAP_M 안 점은 뺀다 — 끝 점과 실오라기.
if (item.key < 0) {
while (us.length > 1 && (u - us[us.length - 1]) * reachOf[si][r] < POINT_GAP_M) {
us.pop();
fixed.pop();
}
// 뺀 점을 가리키던 꼭짓점 번째는 지운다.
index.forEach((map) => map.forEach((at, k) => at >= us.length && map.delete(k)));
}
us.push(u);
fixed.push(1);
note(item.key, item.k, us.length - 1);
}
const row: GridRow = {
u: Float64Array.from(us),
z: Float64Array.from(us, (u) => placeAt(r, si, v, u)[2]),
fixed: Uint8Array.from(fixed),
};
row.fixed[0] = 1;
row.fixed[row.u.length - 1] = 1;
return { row, index };
};
// 사면 격자 — 측마다 격자 행 → Taubin → 점 · 측점 행 횡방향 선 · 꺾임 종방향 선.
[0, 1].forEach((si) => {
const marked: number[] = [];
const grid: Array<GridRow | null> = [];
const indices: VertexIndex[] = [];
for (let r = 0; r < n; r += 1) {
if (!mark[r]) continue;
marked.push(r);
const v = views[si][r];
const made = keyOf[si][r] >= 0 && v ? gridRowOf(r, si, v) : null;
grid.push(made?.row ?? null);
indices.push(made?.index ?? new Map());
}
taubin(grid);
let before: { r: number; ids: Int32Array; index: VertexIndex } | null = null;
marked.forEach((r, g) => {
const row = grid[g];
const v = views[si][r];
if (!row || !v) {
before = null;
return;
}
const ids = new Int32Array(row.u.length).fill(-1);
ids[0] = anchors[si][r];
ids[row.u.length - 1] = daylight[si][r];
for (let k = 1; k < row.u.length - 1; k += 1) {
const [x, y] = placeAt(r, si, v, row.u[k]);
// 꺾임(고정) 점은 솎지 않는다 — 소단 · 측점 단면이 남게.
const keepFixed = row.fixed[k] && daylit(si, r) && stationAt(rows[r].chainage_m) < 0;
if (
nearHole(x, y) ||
(!keepFixed && !(row.fixed[k] && stationAt(rows[r].chainage_m) >= 0) && crowded(x, y))
)
continue;
ids[k] = place(x, y, limited(x, y, row.z[k]), tagOf(v.kind));
stats.gridPoints += 1;
}
if (stationAt(rows[r].chainage_m) >= 0) {
const line = Array.from(ids).filter((id) => id >= 0);
pslg.addPath(
line.filter((id, k) => k === 0 || id !== line[k - 1]),
EDGE_DESIGN,
);
}
// 같은 보기 · 같은 번째 단면 꼭짓점(꺾임)을 이웃 격자 행끼리 잇는다 — 포락선에서 멈춘 행은 뺌.
const prior: { r: number; ids: Int32Array; index: VertexIndex } | null = before;
if (prior && daylit(si, r) && daylit(si, prior.r)) {
for (const [key, now] of indices[g]) {
const then = prior.index.get(key);
if (!then) continue;
for (const [k, at] of now) {
const was = then.get(k);
if (was === undefined || at === 0 || at === row.u.length - 1) continue;
if (was === 0 || was === prior.ids.length - 1) continue;
const a = prior.ids[was];
const b = ids[at];
if (a < 0 || b < 0 || a === b) continue;
pslg.addEdge(a, b, EDGE_DESIGN);
stats.bendLines += 1;
}
}
}
before = { r, ids };
}
before = { r, ids, index: indices[g] };
});
});
// 구조물 윗면 구멍 — 둘레 높이 = min(설계면, 윗면)(제안 ② 와 같은 드레이프).
@@ -442,23 +595,22 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
a0 = a1;
}
if (!best) return null;
const si = best.lateral >= 0 ? 0 : 1;
const at = best;
const si = at.lateral >= 0 ? 0 : 1;
const height = (r: number): number | null => {
const s = Math.abs(best!.lateral) - hw[r];
const s = Math.abs(at.lateral) - hw[r];
if (s <= 0) return anchorOf(r, si)[2];
if (keyOf[si][r] < 0 || s >= reachOf[si][r]) return null;
const [i, t] = bracket(rows[r].chainage_m);
const v = view(i, t, si);
const slope = v && drape(r, si, v);
return slope ? zAt(slope, s) : null;
const v = views[si][r];
if (keyOf[si][r] < 0 || !v || s >= reachOf[si][r] * shownOf[si][r]) return null;
return placeAt(r, si, v, s / reachOf[si][r])[2];
};
const h0 = height(best.r);
const h1 = height(best.r + 1);
const h0 = height(at.r);
const h1 = height(at.r + 1);
if (h0 === null || h1 === null) return null;
return h0 + (h1 - h0) * best.t;
return h0 + (h1 - h0) * at.t;
};
const holes: Array<Array<[number, number]>> = [];
for (const loop of structureTopLoops(build.structures ?? [])) {
for (const loop of topLoops) {
const ids = loop.map(([x, y, top]) => {
const surface = surfaceAt(x, y) ?? groundAt(x, y) ?? top;
return pslg.addPoint(sx(x), sz(y), Math.min(surface, top) - oz, TAG_HOLE);
@@ -0,0 +1,63 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Loft_Grid.ts
* 3D 제안 ③ **사면 격자 면 평활화**(계획서 115-10) — 순수 수치.
*
* 격자 행 = 사면 단면 하나(노견 끝 u = 0 → 비탈 끝 u = 1). 점마다 이웃 = 같은 행 앞뒤 점 · 앞뒤 행의
* 같은 u 자리(그 행 선형 보간). 높이만 Taubin(λ · μ 번갈아 → 줄어들지 않음)으로 고른다.
* 고정 = 노견 끝 · 비탈 끝(지형 위) · 꺾임(소단 · 암/토사) · 측점 행(B06).
* ========================================================================== */
// ponytail: Taubin λ 0.5 · μ −0.53 · 10 번 — 1 m 격자에서 파장 수 m 지형 잡음 · 드레이프 출렁임만 누름 ·
// 더 돌리면 측점 행 고정점 둘레가 오목해짐(측점 오차로 잼).
const LAMBDA = 0.5;
const MU = -0.53;
const ROUNDS = 10;
export interface GridRow {
/** 사면 비율 0(노견 끝) ~ 1(비탈 끝) 오름차순. */
u: Float64Array;
/** 높이(모델) — 평활화가 고쳐 쓴다. */
z: Float64Array;
/** 1 = 고정. */
fixed: Uint8Array;
}
/** 행의 u 자리 높이(선형). */
export function zAtU(row: GridRow, u: number): number {
const { u: us, z } = row;
if (u <= us[0]) return z[0];
for (let k = 1; k < us.length; k += 1) {
if (u <= us[k]) {
const span = us[k] - us[k - 1];
const t = span <= 1e-12 ? 1 : (u - us[k - 1]) / span;
return z[k - 1] + (z[k] - z[k - 1]) * t;
}
}
return z[z.length - 1];
}
/** 한 측 격자 행들(누가거리 차례 · null = 비탈 없음 → 이웃 끊김)의 높이를 Taubin 으로 고른다. */
export function taubin(rows: Array<GridRow | null>, rounds = ROUNDS): void {
const step = (factor: number): void => {
const next = rows.map((row, k) => {
if (!row) return null;
const out = Float64Array.from(row.z);
const before = rows[k - 1] ?? null;
const after = rows[k + 1] ?? null;
for (let j = 1; j < row.u.length - 1; j += 1) {
if (row.fixed[j]) continue;
let sum = row.z[j - 1] + row.z[j + 1];
let count = 2;
if (before) ((sum += zAtU(before, row.u[j])), (count += 1));
if (after) ((sum += zAtU(after, row.u[j])), (count += 1));
out[j] = row.z[j] + factor * (sum / count - row.z[j]);
}
return out;
});
rows.forEach((row, k) => row && row.z.set(next[k] as Float64Array));
};
for (let i = 0; i < rounds; i += 1) {
step(LAMBDA);
step(MU);
}
}
@@ -0,0 +1,154 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Loft_Toe.ts
* 3D 제안 ③ **비탈 끝 선 평활화 · 포락선**(계획서 115-10) — 평면 2D · 순수 기하.
*
* ① 비탈 끝 거리(행마다)를 곡선 안쪽 상한(국부 반경 몫)으로 줄이고 노선 따라 가우스로 고름 →
* 부채꼴 양옆 · 절성토 바뀜이 매끈히 줄어듦 · 측점 행은 단면 값(B06) 그대로.
* ② 포락선 — 두 행의 사면 선(노견 끝 → 비탈 끝)이 평면에서 엇갈리면 그 자리까지 더 먼 쪽이 그 자리에서
* 멈춘다(두 비탈 면의 만나는 선 = 골 · 마루). 앞 행에만 대어 자르던 톱니 · 한 점으로 모이는 원뿔 없음.
* ========================================================================== */
import { crossing, SegGrid } from "./B05_Profile_UI_Corridor_Tin_Pslg";
// ponytail: 비탈 끝 거리 가우스 σ 1.5 m(±4.5 m 창) — 측점 사이 3차 보간은 이미 매끈해 바뀜 자리 ·
// 부채꼴 양끝만 다듬음 · 측점 값은 고정이라 넓혀도 측점 오차는 그대로(사이 모양만 무뎌짐).
const REACH_SIGMA_M = 1.5;
export interface ToeRows {
chainage: ArrayLike<number>;
/** 노면부 끝(앵커) 평면 자리. */
ax: ArrayLike<number>;
ay: ArrayLike<number>;
/** 바깥 법선(단위). */
nx: ArrayLike<number>;
ny: ArrayLike<number>;
/** 단면 비탈 끝 거리 — 0 = 비탈 없음. */
reach: ArrayLike<number>;
/** 단면이 있는 행(비탈을 세울 수 있음). */
has: ArrayLike<number>;
/** 곡선 안쪽 비탈 끝 거리 상한(행마다 · 국부 반경 몫) — 넘으면 줄임. */
maxReach: ArrayLike<number>;
/** 측점 행(1) — 고른 뒤에도 비탈 끝 거리 = 단면 값(B06). */
pinned: ArrayLike<number>;
}
export interface ToeLine {
/** 고른 비탈 끝 거리(지형에 닿는 끝 · 드레이프 기준). */
reach: Float64Array;
/** 보이는 몫(0~1) — 1 = 비탈 끝까지(지형 위) · 1 밑 = 다른 행 사면과 만나는 선에서 멈춤. */
shown: Float64Array;
/** 포락선에서 멈춘 행 수. */
folded: number;
}
/** 가우스 이동 평균(누가거리 기준 · ±3σ) — `only` 가 거짓인 행은 그대로(셈에는 든다). */
export function gauss(
chainage: ArrayLike<number>,
value: ArrayLike<number>,
sigma: number,
only?: (r: number) => boolean,
): Float64Array {
const n = chainage.length;
const out = Float64Array.from(value as ArrayLike<number>);
let lo = 0;
for (let r = 0; r < n; r += 1) {
if (only && !only(r)) continue;
const c = chainage[r];
while (chainage[lo] < c - 3 * sigma) lo += 1;
let sum = 0;
let weight = 0;
for (let q = lo; q < n && chainage[q] <= c + 3 * sigma; q += 1) {
const w = Math.exp(-((chainage[q] - c) ** 2) / (2 * sigma * sigma));
sum += w * value[q];
weight += w;
}
out[r] = weight ? sum / weight : value[r];
}
return out;
}
/** 한 측 비탈 끝 — 거리 상한 · 고르기 · 측점 고정 → 포락선(엇갈린 사면 선은 먼 쪽이 멈춤). */
export function smoothToe(rows: ToeRows): ToeLine {
const n = rows.chainage.length;
const raw = Float64Array.from(rows.reach as ArrayLike<number>, (e, r) =>
Math.min(e, rows.maxReach[r]),
);
for (let r = 0; r < n; r += 1) if (!rows.has[r]) raw[r] = 0;
const reach = gauss(rows.chainage, raw, REACH_SIGMA_M);
// 측점 고정 — 측점에서 고르기로 생긴 차를 측점 사이로 부드럽게(smoothstep) 나눠 되돌림.
const pins: number[] = [];
// 비탈 없는 측점(0)도 고정점 — 이웃 측점의 큰 비탈이 번져 들지 않게.
for (let r = 0; r < n; r += 1) if (rows.pinned[r]) pins.push(r);
if (pins.length) {
const smooth = Float64Array.from(reach);
let p = 0;
for (let r = 0; r < n; r += 1) {
if (!rows.has[r]) continue;
while (p + 1 < pins.length && pins[p + 1] <= r) p += 1;
const a = pins[p];
const b = pins[Math.min(p + 1, pins.length - 1)];
const da = raw[a] - smooth[a];
const db = raw[b] - smooth[b];
const span = rows.chainage[b] - rows.chainage[a];
const t =
r <= a || span <= 1e-9 ? 0 : Math.min(1, (rows.chainage[r] - rows.chainage[a]) / span);
const w = t * t * (3 - 2 * t);
reach[r] = Math.min(rows.maxReach[r], Math.max(0, reach[r] + da + (db - da) * w));
}
}
for (let r = 0; r < n; r += 1) if (!rows.has[r]) reach[r] = 0;
// 포락선 — 사면 선끼리 엇갈린 자리에서 그 자리까지 더 먼 선이 멈춘다(같으면 뒤 행).
const ex = (r: number): number => rows.ax[r] + rows.nx[r] * reach[r];
const ey = (r: number): number => rows.ay[r] + rows.ny[r] * reach[r];
const grid = new SegGrid(2);
for (let r = 0; r < n; r += 1) {
if (reach[r] > 0) grid.add(r, rows.ax[r], rows.ay[r], ex(r), ey(r));
}
const shown = new Float64Array(n).fill(1);
let folded = 0;
for (let r = 0; r < n; r += 1) {
if (reach[r] <= 0) continue;
const x0 = rows.ax[r];
const y0 = rows.ay[r];
const x1 = ex(r);
const y1 = ey(r);
for (const q of grid.near(
Math.min(x0, x1),
Math.min(y0, y1),
Math.max(x0, x1),
Math.max(y0, y1),
)) {
if (q === r) continue;
const hit = crossing(x0, y0, x1, y1, rows.ax[q], rows.ay[q], ex(q), ey(q));
if (!hit) continue;
const mine = hit[0] * reach[r];
const theirs = hit[1] * reach[q];
if (mine > theirs + 1e-6 || (Math.abs(mine - theirs) <= 1e-6 && r > q)) {
shown[r] = Math.min(shown[r], hit[0]);
}
}
if (shown[r] < 1) folded += 1;
}
return { reach, shown, folded };
}
/** 폴리라인 스스로 꼬임 수(이웃 아닌 선분 교차) — 시험 · 측정용. */
export function selfCrossings(x: ArrayLike<number>, y: ArrayLike<number>): number {
const n = x.length;
const grid = new SegGrid(2);
for (let r = 0; r + 1 < n; r += 1) grid.add(r, x[r], y[r], x[r + 1], y[r + 1]);
let count = 0;
for (let i = 0; i + 1 < n; i += 1) {
for (const id of grid.near(
Math.min(x[i], x[i + 1]),
Math.min(y[i], y[i + 1]),
Math.max(x[i], x[i + 1]),
Math.max(y[i], y[i + 1]),
)) {
if (id >= i - 1) continue;
if (crossing(x[id], y[id], x[id + 1], y[id + 1], x[i], y[i], x[i + 1], y[i + 1])) count += 1;
}
}
return count;
}
+140 -4
View File
@@ -52,6 +52,8 @@ const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft"));
const { pairProfiles } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Profile"));
const { selfCrossings } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Toe"));
const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid"));
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
@@ -158,6 +160,10 @@ function trianglesOf(root) {
const v = idx ? idx.getX(t + k) : t + k;
tri.push([p.getX(v), p.getY(v), p.getZ(v)]);
}
// 평면 넓이 0(한 줄에 선 세 점) 삼각형은 보이지 않는다 — 틈 셈에서 뺀다(115-10).
const [a, b, c] = tri;
const area = (b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2]);
if (Math.abs(area) < 1e-9) continue;
out.push({ tri, tin: child.name === "terrain-tin-mesh" });
}
});
@@ -277,6 +283,42 @@ function lostVertices(tris, before, out) {
const results = {};
/** 사면(구역 색) 삼각형 가로세로비(가장 긴 변² / 2 · 넓이) — 10 넘는 수 · 가장 큰 값(115-10). */
function aspectOf(out) {
const mesh = out.root.getObjectByName("terrain-tin-mesh");
const p = mesh.geometry.getAttribute("position");
const keep = mesh.userData.keepColor;
let over = 0;
let max = 0;
let count = 0;
for (let t = 0; t < p.count / 3; t += 1) {
if (!keep[t * 3]) continue;
const v = [0, 1, 2].map((k) => new THREE.Vector3().fromBufferAttribute(p, t * 3 + k));
const l = [0, 1, 2].map((k) => v[k].distanceTo(v[(k + 1) % 3]));
const area = v[1].clone().sub(v[0]).cross(v[2].clone().sub(v[0])).length() / 2;
const ratio = area > 1e-12 ? Math.max(...l) ** 2 / (2 * area) : Infinity;
count += 1;
if (ratio > 10) over += 1;
max = Math.max(max, ratio);
}
return { count, over, max: Math.round(max * 100) / 100 };
}
/** 설계 구역 둘레에서 한 측 비탈 끝 선(노면 끝 뺀 차례) 스스로 꼬임 수. */
function toeCrossings(out) {
const pslg = out.design.pslg;
const tag = new Map();
for (let i = 0; i < pslg.count; i += 1) tag.set(`${pslg.x[i]},${pslg.z[i]}`, pslg.tag[i]);
const xs = [];
const zs = [];
out.design.outer.forEach(([x, z]) => {
if (tag.get(`${x},${z}`) === PSLG.TAG_ANCHOR) return;
xs.push(x);
zs.push(z);
});
return selfCrossings(xs, zs);
}
/**
* 사면 음영(115-9) — 이웃한 사면(구역 색) 삼각형 짝마다 면 꺾임 각 · 같은 정점의 법선 차(음영 단차).
* 꺾임 각 30° 안 짝은 법선이 같아야(부드러움) · 넘는 짝(소단)은 법선이 갈려야(또렷).
@@ -418,6 +460,7 @@ function daylightPoints(out, bounds) {
const wAt = (x) => widthAt.get(Math.round(x * 100));
const kink = Math.abs((wAt(20) - wAt(19)) - (wAt(21) - wAt(20)));
results.straight = {
aspect: aspectOf(out),
stats: out.stats,
gaps: gap.gaps,
overshoot: r6(overshoot),
@@ -448,12 +491,17 @@ function daylightPoints(out, bounds) {
const pts = daylightPoints(out, bounds);
let wrongSide = 0;
let offsetErr = 0;
let overshoot = 0;
pts.forEach((p) => {
if (p.x < 0 || p.x > 20) return;
if (p.tag === PSLG.TAG_FILL && p.x >= 10 + 1e-6) wrongSide += 1;
if (p.tag === PSLG.TAG_CUT && p.x < 10 - 0.2 - 1e-6) wrongSide += 1;
const w = 6 + ((4 - 6) * p.x) / 20;
offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w));
const w = Math.abs(p.y) - HALF;
// 측점(0 · 20)에서 B06 오프셋 6 · 4 · 사이는 두 값 안(115-10 비탈 끝 고르기).
if (Math.abs(p.x) < 1e-6 || Math.abs(p.x - 20) < 1e-6) {
offsetErr = Math.max(offsetErr, Math.abs(w - (p.x < 10 ? 6 : 4)));
}
overshoot = Math.max(overshoot, w - 6, 4 - w);
});
// 성토 점 ↔ 절토 점을 잇는 제약 변(노면 끝 · 지형 말고) — 끊었으면 횡방향으로도 없어야 한다.
const pslg = out.design.pslg;
@@ -477,6 +525,7 @@ function daylightPoints(out, bounds) {
cuts,
crossEdges,
offsetErr: r6(offsetErr),
overshoot: r6(overshoot),
};
}
@@ -532,9 +581,59 @@ function daylightPoints(out, bounds) {
}
}
const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -40, 40, -30, 50));
results.curve = { stats: out.stats, selfCross, gaps: gap.gaps };
results.curve = {
stats: out.stats,
selfCross,
toeCross: toeCrossings(out),
aspect: aspectOf(out),
gaps: gap.gaps,
};
}
// ④-2 직선 20 m → 좌 곡선(90°) → 직선 20 m · 높은 성토(8 m · 비탈 12 m) — 곡선 안쪽 두 직선의 비탈 끝이
// 엇갈리는 자리(제비꼬리) → 포락선(먼 쪽이 만나는 선에서 멈춤) · 꼬임 0 · 틈 0(115-10).
// R 25 m = 흔한 곡선(실오라기 0) · R 10 m = 비탈이 곡률 중심을 넘는 급곡선(꼬임 · 틈 0 · 실오라기 1 % 밑).
// ④-2 직선 20 m → 좌 급곡선(R 10 m · 90°) → 직선 20 m · 높은 성토(8 m · 비탈 12 m) — 곡선 안쪽 두 직선의
// 비탈 끝이 엇갈려 꼬이는 자리(제비꼬리) → 포락선 · 곡률 상한 뒤 꼬임 0 · 실오라기 0 · 틈 0(115-10).
function corner(R) {
const route = [];
const sections = [];
const arc = (Math.PI / 2) * R;
const at = (c) => {
if (c <= 20) return { x: c, y: 0, h: 0 };
if (c <= 20 + arc) {
const a = (c - 20) / R;
return { x: 20 + R * Math.sin(a), y: R - R * Math.cos(a), h: a };
}
return { x: 20 + R, y: R + (c - 20 - arc), h: Math.PI / 2 };
};
const length = 40 + arc;
for (let c = 0; c <= length + 1e-9; c += 1) {
const p = at(c);
route.push({ x: p.x, y: p.y });
}
for (let c = 0; c <= length + 1e-9; c += 10) {
const p = at(c);
sections.push(station(c, p.x, p.y, -Math.sin(p.h), Math.cos(p.h), 92, 1.5));
}
const top = 2 * R + 45;
const bounds = { x: [-30, top], y: [-35, top], z: [80, 110] };
const { out } = run({
sections,
route,
terrainSpec: { bounds, x0: -30, x1: top, y0: -35, y1: top, step: 1, heightAt: () => 92 },
});
const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -30, top, -35, top));
return {
stats: out.stats,
toeCross: toeCrossings(out),
aspect: aspectOf(out),
gaps: gap.gaps,
};
}
results.bend = corner(25);
results.hairpin = corner(10);
// ⑤ 소단 단면 — 측점 0 · 40 소단(1:1.5 로 4 m · 소단 1.5 m · 다시 1:1.5) · 20 은 소단 없음 → 꺾임 종방향 선 · 틈 0.
{
const bounds = { x: [-10, 50], y: [-30, 30], z: [85, 110] };
@@ -578,7 +677,44 @@ function daylightPoints(out, bounds) {
}
}
const gap = gapEdges(trianglesOf(out.root), out, frameOf(bounds, -10, 50, -30, 30));
results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps, shade: shadeOf(out) };
results.berm = {
stats: out.stats,
sectionErr: r6(sectionErr),
gaps: gap.gaps,
shade: shadeOf(out),
aspect: aspectOf(out),
};
}
// ⑤-2 Taubin 면 평활화 — 고정점(노견 끝 · 비탈 끝 · 꺾임) 그대로 · 잡음 줄어듦 · 평균 높이 안 꺼짐.
{
const rows = [];
let seed = 7;
const noise = () => ((seed = (seed * 16807) % 2147483647) / 2147483647 - 0.5) * 0.4;
for (let k = 0; k < 30; k += 1) {
const u = Float64Array.from({ length: 11 }, (_, j) => j / 10);
const z = Float64Array.from(u, (x) => 100 - 6 * x + (x > 0 && x < 1 ? noise() : 0));
const fixed = Uint8Array.from(u, (_, j) => (j === 0 || j === 10 || j === 5 ? 1 : 0));
rows.push({ u, z, fixed });
}
const before = rows.map((row) => Float64Array.from(row.z));
const rough = (zs) => {
let sum = 0;
zs.forEach((z, k) => z.forEach((v, j) => (sum += (v - (100 - 0.6 * j)) ** 2)));
return sum;
};
taubin(rows);
let fixedMoved = 0;
rows.forEach((row, k) =>
row.fixed.forEach((f, j) => (fixedMoved += f && row.z[j] !== before[k][j] ? 1 : 0)),
);
const mean = (zs) => zs.reduce((a, z) => a + z.reduce((b, v) => b + v, 0), 0);
results.taubin = {
fixedMoved,
roughBefore: r6(rough(before)),
roughAfter: r6(rough(rows.map((row) => row.z))),
meanShift: r6(Math.abs(mean(rows.map((row) => row.z)) - mean(before)) / (30 * 11)),
};
}
// ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록.
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""PLAN 115-10 — B05 3D 제안 ③ 사면 모양 평활화 검사.
도우미(helper_115_8_corridor_loft.cjs)를 함께 쓴다. 사면 격자(실오라기 없음) · 비탈 끝 선
꼬임 0(포락선) · Taubin 평활화(고정점 그대로 · 줄어들지 않음) · 측점 단면 = B06 · 이음 틈 0.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_115_8_corridor_loft.cjs")],
capture_output=True,
text=True,
timeout=240,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_slope_grid_no_slivers(results):
# 직선 · 소단 — 사면 삼각형 가로세로비 10 넘는 것 0(행마다 점 둘 실오라기 부채꼴 없음)
for name in ("straight", "berm"):
aspect = results[name]["aspect"]
assert aspect["count"] > 1000 and aspect["over"] == 0, (name, aspect)
def test_curves_few_slivers(results):
# 곡선 안쪽(행이 모이는 자리) · 두 비탈이 만나는 골 둘레만 조금 — 1.5 % 밑
for name in ("curve", "bend", "hairpin"):
aspect = results[name]["aspect"]
assert aspect["over"] <= 0.015 * aspect["count"], (name, aspect)
def test_toe_line_no_self_crossing(results):
for name in ("curve", "bend", "hairpin"):
assert results[name]["toeCross"] == 0, name
# 급곡선(R 10 m) — 엇갈린 사면 선은 만나는 선(골)에서 멈춤
assert results["hairpin"]["stats"]["bowtie"] > 0
def test_no_gap_no_failed_constraint(results):
for name in ("straight", "flip", "curve", "bend", "hairpin", "berm"):
assert results[name]["gaps"] == 0, name
assert results[name]["stats"]["failed"] == 0, name
def test_fixed_edges_and_station_section(results):
r = results["straight"]
assert r["drapeErr"] < 1e-4 # 비탈 끝 = 지형 위(고정)
assert r["sectionErr"] < 1e-4 and r["offsetErr"] < 1e-4 # 측점 단면 · 비탈 끝 = B06
assert results["berm"]["sectionErr"] < 1e-4
def test_taubin_keeps_fixed_and_volume(results):
r = results["taubin"]
assert r["fixedMoved"] == 0 # 노견 끝 · 비탈 끝 · 꺾임 그대로
assert r["roughAfter"] < 0.5 * r["roughBefore"] # 잡음 반 넘게 줄어듦
assert r["meanShift"] < 0.01 # 평균 높이 안 꺼짐(Taubin)
+4 -3
View File
@@ -30,10 +30,10 @@ def results():
def test_between_stations_daylight_monotone_cubic(results):
r = results["straight"]
assert r["rowsChecked"] > 300
assert r["rowsChecked"] > 200 # 비탈 끝 점(0.3 m 밑은 솎음 · 115-10)
assert r["offsetErr"] < 1e-4 # 측점(0 · 20 · 40)에서 비탈 끝 = B06 오프셋 6 · 9 · 6
assert r["overshoot"] < 1e-6 # 사이는 단조 3차 — 두 측점 값 밖으로 안 넘침(115-9)
assert r["kink"] < 0.05 # 꼭대기(측점 20) 양옆 기울기 차 — 직선 보간이면 0.3
assert r["kink"] < 0.1 # 꼭대기(측점 20) 양옆 기울기 차 — 직선 보간이면 0.3
def test_station_section_matches_b06(results):
@@ -63,7 +63,8 @@ def test_fill_cut_change_breaks_like_plan(results):
def test_curve_bowtie_cleared(results):
r = results["curve"]
assert r["stats"]["bowtie"] > 0 and r["selfCross"] == 0
# 115-10 — 곡선 안쪽은 곡률 상한 · 포락선으로(자르지 않음) → 둘레 · 비탈 끝 선 꼬임 0
assert r["selfCross"] == 0 and r["toeCross"] == 0
def test_pairing_bends(results):