feat(B05): 3D 제안 ③ 절성토 서피스 부드럽게 — 사면 삼각형만 꺾임 법선(30° 넘는 소단 꺾임은 또렷) · 측점 사이 단조 3차 보간(측점 = B06) · 드레이프 보정량 ±2 m 고르기(115-9)
- 새 _Corridor_Loft_Shade.ts(three toCreasedNormals · 부드러운 음영 재질 사본 한 벌) - _Tin.ts 망 손질 자리(finish) 하나 — 제안 ② 는 안 넘겨 그대로 - 시험 도우미 셋(115-5 · 115-6 · 115-8)이 three BufferGeometryUtils 를 config/node_modules 에서 풂 - test_115_8 직선 보간 단언 → 측점 = B06 · 넘침 없음 · 꼭대기 꺾임 없음 · 사면 음영 단언 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -37,12 +37,24 @@ import {
|
||||
TAG_FILL,
|
||||
TAG_HOLE,
|
||||
} from "./B05_Profile_UI_Corridor_Tin_Pslg";
|
||||
import { endOf, pairProfiles, stationLofts } from "./B05_Profile_UI_Corridor_Loft_Profile";
|
||||
import {
|
||||
arcFractions,
|
||||
atFraction,
|
||||
endOf,
|
||||
hermite,
|
||||
pairProfiles,
|
||||
pchipSlope,
|
||||
stationLofts,
|
||||
} from "./B05_Profile_UI_Corridor_Loft_Profile";
|
||||
import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade";
|
||||
|
||||
/** 이보다 짧은 비탈은 없는 것으로(비탈 끝 = 노면 끝). */
|
||||
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;
|
||||
/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */
|
||||
const HOLE_REACH_M = 80;
|
||||
|
||||
@@ -110,6 +122,49 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
return [lo, span > 1e-9 ? (c - stations[lo].chainage_m) / span : 0];
|
||||
};
|
||||
const pairs = new Map<number, [Float64Array, Float64Array]>();
|
||||
/**
|
||||
* 측점 사이 단조 3차 보간(115-9) — 짝 맞춘 꼭짓점의 바깥거리 · 높이를 누가거리로. 끝 기울기는 앞뒤
|
||||
* 측점(같은 종류 · 또는 비탈 없음) 단면의 같은 호길이 비율 자리와의 할선으로(PCHIP) · 측점에선 B06 그대로.
|
||||
*/
|
||||
const cubics = new Map<number, { h: number; m0: Float64Array; m1: Float64Array }>();
|
||||
const cubicOf = (i: number, si: number, kind: number, pa: Float64Array, pb: Float64Array) => {
|
||||
const j = Math.min(i + 1, last);
|
||||
const h = stations[j].chainage_m - stations[i].chainage_m;
|
||||
const neighbour = (q: number, from: number) => {
|
||||
if (q < 0 || q > last) return null;
|
||||
const profile = stations[q].sides[si];
|
||||
if (profile && profile.kind !== kind) return null;
|
||||
const gap = Math.abs(stations[q].chainage_m - stations[from].chainage_m);
|
||||
return gap > 1e-6 ? { profile, gap } : null;
|
||||
};
|
||||
const before = neighbour(i - 1, i);
|
||||
const after = j > i ? neighbour(j + 1, j) : null;
|
||||
const fa = arcFractions(pa);
|
||||
const fb = arcFractions(pb);
|
||||
const m0 = new Float64Array(pa.length);
|
||||
const m1 = new Float64Array(pa.length);
|
||||
for (let k = 0; k < pa.length; k += 1) {
|
||||
const d1 = h > 1e-9 ? (pb[k] - pa[k]) / h : 0;
|
||||
const at = k >> 1;
|
||||
m0[k] = before
|
||||
? pchipSlope(
|
||||
before.gap,
|
||||
(pa[k] - atFraction(before.profile, fa[at])[k & 1]) / before.gap,
|
||||
h,
|
||||
d1,
|
||||
)
|
||||
: d1;
|
||||
m1[k] = after
|
||||
? pchipSlope(
|
||||
h,
|
||||
d1,
|
||||
after.gap,
|
||||
(atFraction(after.profile, fb[at])[k & 1] - pb[k]) / after.gap,
|
||||
)
|
||||
: d1;
|
||||
}
|
||||
return { h, m0, m1 };
|
||||
};
|
||||
const view = (i: number, t: number, si: number): View | null => {
|
||||
const a = stations[i].sides[si];
|
||||
const b = stations[Math.min(i + 1, last)].sides[si];
|
||||
@@ -129,9 +184,20 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
}
|
||||
const [pa, pb] = pair;
|
||||
const kind = (a ?? b)?.kind ?? 0;
|
||||
return { key: i * 2, kind, pts: pa.map((v, k) => v + (pb[k] - v) * t) };
|
||||
let cubic = cubics.get(slot);
|
||||
if (!cubic) {
|
||||
cubic = cubicOf(i, si, kind, pa, pb);
|
||||
cubics.set(slot, cubic);
|
||||
}
|
||||
const { h, m0, m1 } = cubic;
|
||||
const pts = pa.map((v, k) => hermite(v, pb[k], m0[k], m1[k], h, t));
|
||||
// 꼭짓점끼리 곡선이 엇갈려 바깥거리가 뒤집히지 않게.
|
||||
for (let k = 2; k < pts.length; k += 2) pts[k] = Math.max(pts[k], pts[k - 2]);
|
||||
return { key: i * 2, kind, pts };
|
||||
};
|
||||
/** 단면을 행에 얹고 드레이프 — 비탈 끝 높이 = 지형 · 보정량은 s / 끝 비율. */
|
||||
/** 고른 드레이프 보정량(행 · 측) — 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];
|
||||
@@ -144,7 +210,10 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
const z = new Float64Array(m);
|
||||
for (let k = 0; k < m; k += 1) {
|
||||
s[k] = v.pts[k * 2];
|
||||
z[k] = anchor[2] + v.pts[k * 2 + 1] + (delta * s[k]) / end;
|
||||
// 고른 몫은 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 };
|
||||
};
|
||||
@@ -181,6 +250,37 @@ 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 keyOf = [0, 1].map(() => new Int32Array(n).fill(-1));
|
||||
const reachOf = [0, 1].map(() => new Float64Array(n));
|
||||
@@ -382,5 +482,7 @@ export function loftTerrain(
|
||||
bounds: ModelBounds,
|
||||
heightIndex: TerrainHeightIndex,
|
||||
): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null {
|
||||
return tinTerrain(split, build, bounds, heightIndex, loftDesign);
|
||||
return tinTerrain(split, build, bounds, heightIndex, loftDesign, (mesh) =>
|
||||
smoothSlopeNormals(mesh),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,3 +169,58 @@ export function pairProfiles(
|
||||
}
|
||||
return [Float64Array.from(outA), Float64Array.from(outB)];
|
||||
}
|
||||
|
||||
/** 꼭짓점마다 단면 전체 호길이 비율(0~1) — 길이가 없으면 차례 비율. */
|
||||
export function arcFractions(pts: Float64Array): Float64Array {
|
||||
const m = pts.length / 2;
|
||||
const out = new Float64Array(m);
|
||||
for (let k = 1; k < m; k += 1) {
|
||||
const ds = pts[k * 2] - pts[k * 2 - 2];
|
||||
const dz = pts[k * 2 + 1] - pts[k * 2 - 1];
|
||||
out[k] = out[k - 1] + Math.hypot(ds, dz);
|
||||
}
|
||||
const total = out[m - 1];
|
||||
for (let k = 0; k < m; k += 1) out[k] = total > 1e-9 ? out[k] / total : m > 1 ? k / (m - 1) : 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 단면 위 호길이 비율 g 자리 [s, dz] — 비탈 없음(null)은 [0, 0]. */
|
||||
export function atFraction(profile: LoftProfile | null, g: number): [number, number] {
|
||||
if (!profile) return [0, 0];
|
||||
const pts = profile.pts;
|
||||
const f = arcFractions(pts);
|
||||
for (let k = 1; k < f.length; k += 1) {
|
||||
if (g <= f[k] || k === f.length - 1) {
|
||||
const span = f[k] - f[k - 1];
|
||||
const t = span <= 1e-12 ? 1 : Math.max(0, Math.min(1, (g - f[k - 1]) / span));
|
||||
return [
|
||||
pts[k * 2 - 2] + (pts[k * 2] - pts[k * 2 - 2]) * t,
|
||||
pts[k * 2 - 1] + (pts[k * 2 + 1] - pts[k * 2 - 1]) * t,
|
||||
];
|
||||
}
|
||||
}
|
||||
return [pts[0], pts[1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* 단조 3차(PCHIP · Fritsch–Butland) 마디 끝 기울기 — 앞 마디(h0, d0) · 뒤 마디(h1, d1) 할선.
|
||||
* 부호가 다르거나 0 이면 0(넘침 없음) · 가중 조화 평균은 3 × 할선을 넘지 않는다.
|
||||
*/
|
||||
export function pchipSlope(h0: number, d0: number, h1: number, d1: number): number {
|
||||
if (d0 * d1 <= 0) return 0;
|
||||
const w1 = 2 * h1 + h0;
|
||||
const w2 = h1 + 2 * h0;
|
||||
return (w1 + w2) / (w1 / d0 + w2 / d1);
|
||||
}
|
||||
|
||||
/** 3차 Hermite — 마디 [0, h] 의 t(0~1) 자리 · 끝값 y0 · y1 · 끝 기울기 m0 · m1. */
|
||||
export function hermite(y0: number, y1: number, m0: number, m1: number, h: number, t: number) {
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
return (
|
||||
(2 * t3 - 3 * t2 + 1) * y0 +
|
||||
(t3 - 2 * t2 + t) * h * m0 +
|
||||
(-2 * t3 + 3 * t2) * y1 +
|
||||
(t3 - t2) * h * m1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Loft_Shade.ts
|
||||
* 3D 제안 ③ 사면 **부드러운 음영**(계획서 115-9) — TIN 망에서 절토 · 성토 구역 삼각형만 정점
|
||||
* 법선을 같은 자리끼리 평균(꺾임 각 넘는 곳은 나눔 → 소단 · 노견 끝 · 암/토사 꺾임은 또렷).
|
||||
* 지형 · 구멍 둘레 삼각형은 면 법선 그대로(flatShading 과 같은 모양). 구역 색은 손대지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import * as THREE from "three";
|
||||
import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
|
||||
// ponytail: 꺾임 각 30° — 측점 사이 비틀림 · 1:1.0 ↔ 1:1.5(약 11°)는 고르게, 소단(1:1.5 ↔ 수평 34°)은
|
||||
// 또렷하게. 암/토사(1:0.5 ↔ 1:1.2 약 24°)도 또렷이 남기려면 20° 로 낮춤 · 뭉개짐 대 각짐 맞바꿈은 이 값 하나.
|
||||
export const CREASE_DEG = 30;
|
||||
|
||||
/** 같은 지형 재질에서 만든 부드러운 음영 사본 — 다시 그릴 때마다 새로 만들지 않는다. */
|
||||
const smoothCopies = new WeakMap<THREE.Material, THREE.Material>();
|
||||
|
||||
function smoothMaterial(material: THREE.Material): THREE.Material {
|
||||
let copy = smoothCopies.get(material);
|
||||
if (!copy) {
|
||||
copy = material.clone();
|
||||
(copy as THREE.MeshStandardMaterial).flatShading = false;
|
||||
copy.needsUpdate = true;
|
||||
smoothCopies.set(material, copy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** TIN 망(비색인 · `userData.keepColor` = 구역 색 정점) — 사면 삼각형만 꺾임 법선 · 나머지 면 법선. */
|
||||
export function smoothSlopeNormals(mesh: THREE.Mesh, creaseDeg = CREASE_DEG): void {
|
||||
const geometry = mesh.geometry;
|
||||
const position = geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const keep = mesh.userData.keepColor as Uint8Array | undefined;
|
||||
const count = position.count;
|
||||
const normals = new Float32Array(count * 3);
|
||||
const slope: number[] = [];
|
||||
const a = new THREE.Vector3();
|
||||
const b = new THREE.Vector3();
|
||||
const c = new THREE.Vector3();
|
||||
for (let t = 0; t < count / 3; t += 1) {
|
||||
a.fromBufferAttribute(position, t * 3);
|
||||
b.fromBufferAttribute(position, t * 3 + 1);
|
||||
c.fromBufferAttribute(position, t * 3 + 2);
|
||||
// three computeVertexNormals 와 같은 감기 — (c − b) × (a − b).
|
||||
c.sub(b).cross(a.sub(b)).normalize();
|
||||
for (let k = 0; k < 3; k += 1) normals.set([c.x, c.y, c.z], (t * 3 + k) * 3);
|
||||
if (keep?.[t * 3]) slope.push(t);
|
||||
}
|
||||
if (slope.length) {
|
||||
const part = new THREE.BufferGeometry();
|
||||
const sub = new Float32Array(slope.length * 9);
|
||||
const flat = position.array as Float32Array;
|
||||
slope.forEach((t, i) => sub.set(flat.subarray(t * 9, t * 9 + 9), i * 9));
|
||||
part.setAttribute("position", new THREE.BufferAttribute(sub, 3));
|
||||
const creased = toCreasedNormals(part, (creaseDeg * Math.PI) / 180).getAttribute("normal");
|
||||
const smooth = creased.array as Float32Array;
|
||||
slope.forEach((t, i) => normals.set(smooth.subarray(i * 9, i * 9 + 9), t * 9));
|
||||
part.dispose();
|
||||
}
|
||||
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
|
||||
if (!Array.isArray(mesh.material)) mesh.material = smoothMaterial(mesh.material);
|
||||
}
|
||||
@@ -196,6 +196,7 @@ const deformDesign: TinDesigner = (build, groundAt, bounds) => {
|
||||
* 제안 ② 지형 — 밴드 조각에서 설계 구역에 닿는 삼각형을 단일 TIN 으로 바꾼다. 설계를 못
|
||||
* 세우면(노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 다른 방식으로 그린다.
|
||||
* `designer` 를 바꾸면 지형 이음 · 구멍 · 구역 색은 그대로 두고 설계 점 원천만 바뀐다(제안 ③).
|
||||
* `finish` = 다 만든 TIN 망 손질(제안 ③ 사면 부드러운 법선).
|
||||
*/
|
||||
export function tinTerrain(
|
||||
split: TerrainBandSplit,
|
||||
@@ -203,6 +204,8 @@ export function tinTerrain(
|
||||
bounds: ModelBounds,
|
||||
heightIndex: TerrainHeightIndex,
|
||||
designer: TinDesigner = deformDesign,
|
||||
/** 망을 다 만든 뒤 손질(제안 ③ 사면 법선) — 없으면 그대로. */
|
||||
finish?: (mesh: THREE.Mesh) => void,
|
||||
): { root: THREE.Object3D; stats: TinStats; design: TinDesign } | null {
|
||||
const started = performance.now();
|
||||
if (!build.sections?.length) return null;
|
||||
@@ -352,6 +355,7 @@ export function tinTerrain(
|
||||
mesh.name = "terrain-tin-mesh";
|
||||
mesh.userData.owned = true;
|
||||
mesh.userData.keepColor = Uint8Array.from(keep);
|
||||
finish?.(mesh);
|
||||
root.add(mesh);
|
||||
split.far.forEach((part) => {
|
||||
const far = new THREE.Mesh(part.geometry, part.material);
|
||||
|
||||
Reference in New Issue
Block a user