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:
@@ -14,6 +14,8 @@ const NM = path.join(ROOT, "config", "node_modules");
|
||||
const LIBS = {
|
||||
// delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함).
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
// three 부록(ESM 전용) — Node 22 require(esm).
|
||||
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
|
||||
@@ -13,6 +13,8 @@ const LIBS = {
|
||||
// 기본 내보내기(ESM default) 꼴로 감싼다.
|
||||
// delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함).
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
// three 부록(ESM 전용) — Node 22 require(esm).
|
||||
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
|
||||
@@ -13,6 +13,8 @@ const LIBS = {
|
||||
// 기본 내보내기(ESM default) 꼴로 감싼다.
|
||||
// delaunator 는 ESM 전용 — Node 22 require(esm) 가 내주는 이름공간(default 포함).
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
// three 부록(ESM 전용) — Node 22 require(esm).
|
||||
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
@@ -274,6 +276,70 @@ function lostVertices(tris, before, out) {
|
||||
|
||||
|
||||
const results = {};
|
||||
|
||||
/**
|
||||
* 사면 음영(115-9) — 이웃한 사면(구역 색) 삼각형 짝마다 면 꺾임 각 · 같은 정점의 법선 차(음영 단차).
|
||||
* 꺾임 각 30° 안 짝은 법선이 같아야(부드러움) · 넘는 짝(소단)은 법선이 갈려야(또렷).
|
||||
*/
|
||||
function shadeOf(out) {
|
||||
const mesh = out.root.getObjectByName("terrain-tin-mesh");
|
||||
const p = mesh.geometry.getAttribute("position");
|
||||
const nrm = mesh.geometry.getAttribute("normal");
|
||||
const keep = mesh.userData.keepColor;
|
||||
const vkey = (v) => `${Math.round(p.getX(v) * 1e3)},${Math.round(p.getY(v) * 1e3)},${Math.round(p.getZ(v) * 1e3)}`;
|
||||
const face = (t) => {
|
||||
const a = new THREE.Vector3().fromBufferAttribute(p, t * 3);
|
||||
const b = new THREE.Vector3().fromBufferAttribute(p, t * 3 + 1);
|
||||
const c = new THREE.Vector3().fromBufferAttribute(p, t * 3 + 2);
|
||||
return c.sub(b).cross(a.sub(b)).normalize();
|
||||
};
|
||||
const deg = (a, b) => (Math.acos(Math.max(-1, Math.min(1, a.dot(b)))) * 180) / Math.PI;
|
||||
const edges = new Map();
|
||||
for (let t = 0; t < p.count / 3; t += 1) {
|
||||
if (!keep[t * 3]) continue;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
const a = t * 3 + k;
|
||||
const b = t * 3 + ((k + 1) % 3);
|
||||
const ka = vkey(a);
|
||||
const kb = vkey(b);
|
||||
const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||
const list = edges.get(e) ?? [];
|
||||
list.push({ t, a: ka, va: a, vb: b, kb });
|
||||
edges.set(e, list);
|
||||
}
|
||||
}
|
||||
const jumps = [];
|
||||
let creasePairs = 0;
|
||||
let creaseJumpMin = 180;
|
||||
for (const list of edges.values()) {
|
||||
if (list.length !== 2) continue;
|
||||
const [x, y] = list;
|
||||
const fx = face(x.t);
|
||||
const fy = face(y.t);
|
||||
if (fx.length() < 0.5 || fy.length() < 0.5) continue;
|
||||
const bend = deg(fx, fy);
|
||||
// 같은 자리 정점(두 삼각형 쪽) 법선 차.
|
||||
const other = (key) => (y.a === key ? y.va : y.vb);
|
||||
const jump = Math.max(
|
||||
deg(new THREE.Vector3().fromBufferAttribute(nrm, x.va), new THREE.Vector3().fromBufferAttribute(nrm, other(x.a))),
|
||||
deg(new THREE.Vector3().fromBufferAttribute(nrm, x.vb), new THREE.Vector3().fromBufferAttribute(nrm, other(x.kb))),
|
||||
);
|
||||
if (bend < 25) {
|
||||
jumps.push(jump);
|
||||
} else if (bend > 33) {
|
||||
creasePairs += 1;
|
||||
creaseJumpMin = Math.min(creaseJumpMin, jump);
|
||||
}
|
||||
}
|
||||
return {
|
||||
smoothPairs: jumps.length,
|
||||
smoothJumpQ50: r6(jumps.sort((a, b) => a - b)[Math.floor(jumps.length / 2)]),
|
||||
smoothJumpQ90: r6(jumps[Math.floor(jumps.length * 0.9)]),
|
||||
creasePairs,
|
||||
creaseJumpMin: r6(creaseJumpMin),
|
||||
smoothMaterial: mesh.material.flatShading === false,
|
||||
};
|
||||
}
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
const STRAIGHT = [
|
||||
{ x: 0, y: 0 },
|
||||
@@ -318,14 +384,21 @@ function daylightPoints(out, bounds) {
|
||||
const { out, before, positions, heightIndex } = run({ sections, route: STRAIGHT, terrainSpec: spec });
|
||||
const tris = trianglesOf(out.root);
|
||||
const gap = gapEdges(tris, out, frameOf(bounds, -10, 50, -25, 25));
|
||||
// 비탈 끝 오프셋 — 측점 사이 = 6 → 9 → 6(+ 노면 반폭) 직선 보간.
|
||||
// 비탈 끝 오프셋 — 측점(0 · 20 · 40)에서 6 · 9 · 6(+ 노면 반폭) 그대로 · 사이는 단조 3차(115-9):
|
||||
// 두 측점 값 밖으로 안 넘침 · 꼭대기(측점 20)에서 꺾이지 않음(양옆 기울기 차 작음).
|
||||
let offsetErr = 0;
|
||||
let overshoot = 0;
|
||||
let drapeErr = 0;
|
||||
let rowsChecked = 0;
|
||||
const widthAt = new Map();
|
||||
daylightPoints(out, bounds).forEach((p) => {
|
||||
if (p.x < 0 || p.x > 40) return;
|
||||
const w = p.x <= 20 ? 6 + (3 * p.x) / 20 : 9 - (3 * (p.x - 20)) / 20;
|
||||
offsetErr = Math.max(offsetErr, Math.abs(Math.abs(p.y) - HALF - w));
|
||||
const w = Math.abs(p.y) - HALF;
|
||||
if (p.y > 0) widthAt.set(Math.round(p.x * 100), w);
|
||||
if ([0, 20, 40].some((x) => Math.abs(p.x - x) < 1e-6)) {
|
||||
offsetErr = Math.max(offsetErr, Math.abs(w - (Math.abs(p.x - 20) < 1e-6 ? 9 : 6)));
|
||||
}
|
||||
overshoot = Math.max(overshoot, w - 9, 6 - w);
|
||||
const mesh = heightIndex.heightAt(p.x - 20, -p.y) + 100; // 망 지형(격자 삼각형) 높이
|
||||
drapeErr = Math.max(drapeErr, Math.abs(p.z - mesh));
|
||||
rowsChecked += 1;
|
||||
@@ -341,9 +414,14 @@ function daylightPoints(out, bounds) {
|
||||
}
|
||||
}
|
||||
// 측점 사이(x = 10) 단면은 지형에 맞춰 다시 그리지 않는다 — 노면 끝 0 → 비탈 끝 1 비율 보정.
|
||||
// 꼭대기 양옆 기울기 — 직선 보간이면 +0.15 · −0.15(차 0.3) · 3차면 둘 다 0 근처.
|
||||
const wAt = (x) => widthAt.get(Math.round(x * 100));
|
||||
const kink = Math.abs((wAt(20) - wAt(19)) - (wAt(21) - wAt(20)));
|
||||
results.straight = {
|
||||
stats: out.stats,
|
||||
gaps: gap.gaps,
|
||||
overshoot: r6(overshoot),
|
||||
kink: r6(kink),
|
||||
rowsChecked,
|
||||
offsetErr: r6(offsetErr),
|
||||
drapeErr: r6(drapeErr),
|
||||
@@ -500,7 +578,7 @@ 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 };
|
||||
results.berm = { stats: out.stats, sectionErr: r6(sectionErr), gaps: gap.gaps, shade: shadeOf(out) };
|
||||
}
|
||||
|
||||
// ⑥ 3D 방식 등록부 — 「제안 ③」 자리 · 등록.
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
"""PLAN 115-8 — B05 3D 제안 ③ 측점 횡단 기반 사면 검사.
|
||||
|
||||
Node 도우미(helper_115_8_corridor_loft.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
|
||||
결과(JSON)를 단언한다. 사이 비탈 끝 = 오프셋 직선 보간 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 ·
|
||||
드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부.
|
||||
결과(JSON)를 단언한다. 측점 비탈 끝 = B06 오프셋 · 측점 단면 = B06 · 성토 ↔ 절토 끊음 ·
|
||||
드레이프 뒤 비탈 끝 = 지형 · 틈 0 · 곡선 안쪽 겹침 청소 · 짝 맞추기 · 등록부 ·
|
||||
단조 3차 보간 넘침 없음 · 사면 음영(115-9).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -27,10 +28,12 @@ def results():
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_between_stations_daylight_is_offset_lerp(results):
|
||||
def test_between_stations_daylight_monotone_cubic(results):
|
||||
r = results["straight"]
|
||||
assert r["rowsChecked"] > 300
|
||||
assert r["offsetErr"] < 1e-4 # 비탈 끝 = 측점 오프셋 6 → 9 → 6 직선 보간(평면도 외곽선)
|
||||
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
|
||||
|
||||
|
||||
def test_station_section_matches_b06(results):
|
||||
@@ -69,6 +72,14 @@ def test_pairing_bends(results):
|
||||
assert results["berm"]["stats"]["bendLines"] > 0 # 같은 번째 꼭짓점 종방향 선
|
||||
|
||||
|
||||
def test_slope_shading_smooth_and_berm_crisp(results):
|
||||
r = results["berm"]["shade"]
|
||||
assert r["smoothMaterial"] is True # 사면 망만 부드러운 음영 재질 사본
|
||||
assert r["smoothPairs"] > 500
|
||||
assert r["smoothJumpQ50"] < 0.5 and r["smoothJumpQ90"] < 1 # 꺾임 각 안 이웃 면 = 같은 법선
|
||||
assert r["creasePairs"] > 0 and r["creaseJumpMin"] > 25 # 소단 꺾임(34°)은 또렷이 남음
|
||||
|
||||
|
||||
def test_registered_as_proposal3(results):
|
||||
r = results["registry"]
|
||||
assert r["slot"] is True and r["registered"] is True and r["hasTerrain"] is True
|
||||
|
||||
Reference in New Issue
Block a user