feat(B05): 3D 예상형상 새 방식 — 지형 메시 높이만 설계면으로 옮기는 변형 코어 · 새/옛 전환 단추(115-1)
- 정점 → (행 · 편거리) 역투영(행 중심 격자 + 이웃 행 걷기) · z' = clamp(지형, 성토선, 절토선) - 비탈 끝(첫 지반 교차점) 밖은 원지반 · 단면 밖 마지막 구배 연장 · 측점 사이 제어점 보간 - 노면부 리본 유지 · 지형은 노면부 끝에서 자르고 경계 z = 앵커 z · 사본에만 · XY 고정 - 색 = 높이 변화 부호(절토 · 성토 색 · 빗금 없음 · 흑백 지형에서도 유지) - 3D 보기 줄 「새 방식」 단추(기본 켬 · pref 등록) — 옛 방식 코드는 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/* PLAN 115 단위검증 헬퍼 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const THREE = require(path.join(ROOT, "config", "node_modules", "three"));
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request === "three") return THREE;
|
||||
// 화면 모듈(@ui · @config)은 코리도 계산이 부르지 않는다 — 빈 껍데기로 넘긴다.
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
|
||||
return new Proxy({}, { get: () => () => undefined });
|
||||
}
|
||||
const target = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
||||
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
|
||||
const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform"));
|
||||
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"));
|
||||
|
||||
const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000);
|
||||
const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5)
|
||||
const ROAD_Z = 100;
|
||||
|
||||
/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */
|
||||
function station(chainage, mode, groundZ, ratio) {
|
||||
const w = Math.abs(groundZ - ROAD_Z) * ratio;
|
||||
const pts = [
|
||||
[-30, groundZ],
|
||||
[-(HALF + w), groundZ],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[HALF + w, groundZ],
|
||||
[30, groundZ],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: chainage,
|
||||
center_y: 0,
|
||||
center_z: groundZ,
|
||||
frame: { left_xy: [0, 1] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: mode,
|
||||
road_edges: {
|
||||
left: { offset_m: HALF, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -HALF, elevation_m: ROAD_Z },
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -2.5, elevation_m: ROAD_Z },
|
||||
},
|
||||
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
ditch: { type: "none" },
|
||||
ditch_enabled: false,
|
||||
ditch_side: "left",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ROUTE = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
];
|
||||
|
||||
function fieldOf(sections, groundAt) {
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
const field = deform.buildDeformField(build, deform.stationProfileSource(sections), groundAt);
|
||||
return { build, field };
|
||||
}
|
||||
|
||||
const results = {};
|
||||
|
||||
// ① 성토 — 지반 96 · 1:1.5. 비탈 안 정점은 성토선으로 올라가고, 끝(6 m) 밖은 그대로.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const { build, field } = fieldOf(sections, () => 96);
|
||||
results.fill = {
|
||||
rows: field.rows.length,
|
||||
anchorL: field.rows[10].left.map(r4),
|
||||
left2: r4(field.deformZ(10, HALF + 2, 96)),
|
||||
right2: r4(field.deformZ(10, -HALF - 2, 96)),
|
||||
boundary: r4(field.deformZ(10, HALF, 96)),
|
||||
beyond: r4(field.deformZ(10, HALF + 9, 96)),
|
||||
// 지반이 성토선보다 높은 정점은 그대로(clamp 아래쪽만 올린다).
|
||||
above: r4(field.deformZ(10, HALF + 2, 99.5)),
|
||||
oldRibbons: build.ribbons.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ② 첫 지반 교차점 밖은 원지반 — 비탈 끝(6 m) 너머 골짜기(80)는 연장선(92)으로 메우지 않는다.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const groundAt = (x, y) => (Math.abs(y) - HALF > 10 ? 80 : 96);
|
||||
const { field } = fieldOf(sections, groundAt);
|
||||
results.valley = {
|
||||
inside: r4(field.deformZ(10, HALF + 2, 96)),
|
||||
farValley: r4(field.deformZ(10, HALF + 15, 80)),
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 절토 — 지반 104 · 1:1. 비탈 안 정점은 절토선으로 내려가고 끝(4 m) 밖은 그대로.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_cut", 104, 1.0));
|
||||
const { field } = fieldOf(sections, () => 104);
|
||||
results.cut = {
|
||||
left2: r4(field.deformZ(10, HALF + 2, 104)),
|
||||
beyond: r4(field.deformZ(10, HALF + 6, 104)),
|
||||
below: r4(field.deformZ(10, HALF + 2, 101)),
|
||||
};
|
||||
}
|
||||
|
||||
// ④ 측점 사이 보간 — 0 측점 1:1.5, 40 측점 1:2. 가운데(20)는 앵커 기준 상대 단면 보간.
|
||||
{
|
||||
const sections = [station(0, "both_fill", 96, 1.5), station(40, "both_fill", 96, 2.0)];
|
||||
const { field } = fieldOf(sections, () => 96);
|
||||
results.taper = {
|
||||
at0: r4(field.deformZ(0.1, HALF + 2, 96)),
|
||||
at20: r4(field.deformZ(20, HALF + 2, 96)),
|
||||
at40: r4(field.deformZ(39.9, HALF + 2, 96)),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑤ 단면 밖 마지막 구배 연장 · 반대 측 빌리기 — 단면 함수 단독.
|
||||
{
|
||||
const profile = Float64Array.from([0, 0, 2, -1, 4, -2]);
|
||||
results.profile = {
|
||||
mid: r4(deform.profileZ(profile, 1)),
|
||||
extend: r4(deform.profileZ(profile, 10)),
|
||||
noExtend: deform.profileZ(profile, 10, true),
|
||||
};
|
||||
}
|
||||
|
||||
// ⑥ Three 지형 — 사본에만 · XY 고정 · 노면부 구멍 · 색 · 흑백 보존 표식.
|
||||
{
|
||||
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
|
||||
const build = buildCorridor(sections, ROUTE);
|
||||
build.sections = sections;
|
||||
const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] };
|
||||
const cx = 20;
|
||||
const cy = 0;
|
||||
const cz = 100;
|
||||
const step = 0.5;
|
||||
const nx = Math.round(60 / step) + 1;
|
||||
const ny = Math.round(40 / step) + 1;
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
const colors = new Uint8Array(nx * ny * 4).fill(200);
|
||||
for (let j = 0; j < ny; j += 1) {
|
||||
for (let i = 0; i < nx; i += 1) {
|
||||
const v = j * nx + i;
|
||||
positions[v * 3] = -10 + i * step - cx;
|
||||
positions[v * 3 + 1] = 96 - cz;
|
||||
positions[v * 3 + 2] = -(-20 + j * step - 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));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true));
|
||||
geometry.setIndex(index);
|
||||
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
|
||||
const before = positions.slice();
|
||||
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
|
||||
const heightIndex = new TerrainHeightIndex(terrain);
|
||||
const out = deformTerrain(split, build, bounds, heightIndex);
|
||||
let xyMoved = 0;
|
||||
let inRoad = 0;
|
||||
let probe = null;
|
||||
let keepCount = 0;
|
||||
let tinted = 0;
|
||||
out.root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const c = child.geometry.getAttribute("color");
|
||||
for (let v = 0; v < nx * ny; v += 1) {
|
||||
if (p.getX(v) !== before[v * 3] || p.getZ(v) !== before[v * 3 + 2]) xyMoved += 1;
|
||||
const mx = p.getX(v) + cx;
|
||||
const my = -p.getZ(v) + cy;
|
||||
if (Math.abs(mx - 10) < 1e-6 && Math.abs(my - (HALF + 2)) < 1e-6) probe = p.getY(v) + cz;
|
||||
if (child.userData.keepColor[v]) {
|
||||
keepCount += 1;
|
||||
if (c.getX(v) !== 200 / 255) tinted += 1;
|
||||
}
|
||||
}
|
||||
const idx = child.geometry.getIndex();
|
||||
for (let t = 0; t < idx.count; t += 3) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
sx += p.getX(idx.getX(t + k)) + cx;
|
||||
sy += -p.getZ(idx.getX(t + k)) + cy;
|
||||
}
|
||||
sx /= 3;
|
||||
sy /= 3;
|
||||
if (sx > 0.5 && sx < 39.5 && Math.abs(sy) < HALF - 0.05) inRoad += 1;
|
||||
}
|
||||
});
|
||||
results.three = {
|
||||
originalUntouched: before.every((value, i) => value === positions[i]),
|
||||
xyMoved,
|
||||
inRoad,
|
||||
probe: r4(probe),
|
||||
moved: out.stats.moved,
|
||||
raised: out.stats.raised,
|
||||
keepCount,
|
||||
tinted,
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(results));
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 115 — B05 3D 새 방식(지형 메시 변형) 합성 격자 검사.
|
||||
|
||||
Node 도우미(helper_115_corridor_deform.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는
|
||||
결과(JSON)를 단언한다. 정점 규칙 `z' = clamp(지형, 성토선, 절토선)` · 비탈 끝 밖 원지반 ·
|
||||
측점 사이 보간 · 단면 밖 마지막 구배 연장 · 사본에만 · XY 고정 · 노면부 구멍 · 색.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FILL_2M = round(100 - 2 / 1.5, 4)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_115_corridor_deform.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_fill_clamp_raises_to_fill_line(results):
|
||||
r = results["fill"]
|
||||
assert r["rows"] >= 2
|
||||
assert r["anchorL"][1:] == [3, 100]
|
||||
assert r["left2"] == FILL_2M
|
||||
assert r["right2"] == FILL_2M
|
||||
assert r["boundary"] == 100 # 노면부 끝 경계 정점 = 앵커 z
|
||||
assert r["beyond"] == 96 # 비탈 끝(6 m) 밖은 원지반
|
||||
assert r["above"] == 99.5 # 지반이 성토선 위면 그대로
|
||||
|
||||
|
||||
def test_fill_stops_at_first_catch(results):
|
||||
r = results["valley"]
|
||||
assert r["inside"] == FILL_2M
|
||||
assert r["farValley"] == 80 # 연장선(92)으로 골짜기를 메우지 않는다
|
||||
|
||||
|
||||
def test_cut_clamp_lowers_to_cut_line(results):
|
||||
r = results["cut"]
|
||||
assert r["left2"] == 102
|
||||
assert r["beyond"] == 104
|
||||
assert r["below"] == 101 # 지반이 절토선 아래면 그대로
|
||||
|
||||
|
||||
def test_station_interpolation(results):
|
||||
r = results["taper"]
|
||||
assert abs(r["at0"] - FILL_2M) < 0.02
|
||||
assert abs(r["at40"] - 99.0) < 0.02
|
||||
assert abs(r["at20"] - round(100 - 2 * 4 / 7, 4)) < 1e-3 # 상대 단면 보간
|
||||
|
||||
|
||||
def test_profile_extends_last_grade(results):
|
||||
r = results["profile"]
|
||||
assert r["mid"] == -0.5
|
||||
assert r["extend"] == -5
|
||||
assert r["noExtend"] is None
|
||||
|
||||
|
||||
def test_three_copy_only_xy_fixed_hole_and_tint(results):
|
||||
r = results["three"]
|
||||
assert r["originalUntouched"] is True # 원본 정점 버퍼 그대로([예상형상] OFF = 원본)
|
||||
assert r["xyMoved"] == 0
|
||||
assert r["inRoad"] == 0 # 노면부 안 삼각형 없음
|
||||
assert r["probe"] == FILL_2M
|
||||
assert r["moved"] > 0 and r["raised"] == r["moved"]
|
||||
assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]
|
||||
Reference in New Issue
Block a user