feat(B05): 3D 옛 방식 A(08-25 실루엣 갈아 끼우기) 별개 단추 — 배수관 세트만 실루엣 · 스케치 · 자르기 · 패치 없음 · 지형 띠만 자름 · 저장 안 함(브라우저 즉석 · 해시별 메모)(115-4)

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 20:52:32 +09:00
co-authored by Claude Opus 5.5
parent 2ecbd9f49b
commit bf1106aa8e
9 changed files with 375 additions and 26 deletions
@@ -0,0 +1,148 @@
/* PLAN 115-4 단위검증 헬퍼 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 빌드.
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 입력 = 픽스처(배수관 세트 · BOX · 세월교 측점).
* 결과는 표준출력에 JSON 한 벌. 짝 = `test_115_4_corridor_legacy.py`.
*/
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);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
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 base = request.startsWith("@util/")
? path.join(ROOT, "common_util", request.slice(6))
: path.join(path.dirname(full), request);
const found = [base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
);
return loadTs(found ?? `${base}.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 { classifyStation } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
const fixture = (name) =>
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", `${name}.json`), "utf8"))
.cross_sections;
const r3 = (v) => Math.round(v * 1000) / 1000;
/** 양 끝을 잇는 직선에서 가장 먼 점의 거리(m) — 직선 비탈이면 0. */
function bend(points) {
const a = points[0];
const b = points[points.length - 1];
const dx = b.offset_m - a.offset_m;
const dz = b.elevation_m - a.elevation_m;
const len = Math.hypot(dx, dz) || 1;
return Math.max(
...points.map(
(p) => Math.abs((p.offset_m - a.offset_m) * dz - (p.elevation_m - a.elevation_m) * dx) / len,
),
);
}
const results = {};
// ① 배수관 세트 측점(85.052 · 우절토 · 유출 좌측 성토) — 픽스처에 평면 자리가 없어 직선 노선에 얹는다.
{
const sections = fixture("b07_cross_overlay_sections").map((s) => ({
...s,
station_id: `S${s.chainage_m}`,
center_x: s.chainage_m,
center_y: 0,
frame: { left_xy: [0, 1] },
}));
const set = sections.find((s) => s.culvert);
const plain = classifyStation(set);
const legacy = classifyStation(set, { silhouette: true });
const side = set.uphill_side === "right" ? "left" : "right"; // 유출측
const key = `fill:${side}`;
const p0 = plain.pieces.get(key);
const p1 = legacy.pieces.get(key);
results.set = {
chainage: set.chainage_m,
key,
plainBend: r3(bend(p0)),
legacyBend: r3(bend(p1)),
plainRatio: r3(
Math.abs(p0[p0.length - 1].offset_m - p0[0].offset_m) /
Math.abs(p0[p0.length - 1].elevation_m - p0[0].elevation_m),
),
wallSpans: Object.keys(legacy.wallSpans ?? {}).sort(),
plainWallSpans: plain.wallSpans ?? null,
indexArg:
JSON.stringify([...classifyStation(set, 3).pieces]) === JSON.stringify([...plain.pieces]),
};
const route = sections.map((s) => ({ x: s.center_x, y: 0 }));
const base = buildCorridor(sections, route);
const old = buildCorridor(sections, route, undefined, undefined, { legacy: true });
const ribbonAt = (build) =>
build.ribbons.find((r) => r.kind === "fill" && r.side === side && !r.patch);
const rowAt = (ribbon, chainage) => {
let best = 0;
ribbon.chainages.forEach((c, i) => {
if (Math.abs(c - chainage) < Math.abs(ribbon.chainages[best] - chainage)) best = i;
});
const pts = [];
for (let c = 0; c < ribbon.colCount; c += 1) {
const i = (best * ribbon.colCount + c) * 3;
pts.push({ offset_m: ribbon.positions[i + 1], elevation_m: ribbon.positions[i + 2] });
}
return pts;
};
results.build = {
basePlanCurves: Array.isArray(base.planCurves),
baseCutWalls: Array.isArray(base.cutWalls),
keys: Object.keys(old).sort(),
patches: old.ribbons.filter((r) => r.patch).length,
structures: old.structures.length,
rowBend: r3(bend(rowAt(ribbonAt(old), set.chainage_m))),
baseRowBend: r3(bend(rowAt(ribbonAt(base), set.chainage_m))),
rows: ribbonAt(old).chainages.length,
};
}
// ② BOX(352.14) · 세월교(173.087) 측점 — 옛 방식 A 에서도 비탈은 원본(실루엣 없음).
{
const sections = fixture("b06_73_4_body_guard_sections");
results.others = sections
.filter((s) => s.box || s.ford)
.map((s) => {
const plain = classifyStation(s);
const legacy = classifyStation(s, { silhouette: true });
const slopes = (st) =>
JSON.stringify(
[...st.pieces].filter(([k]) => k.startsWith("cut:") || k.startsWith("fill:")),
);
return {
chainage: s.chainage_m,
kind: s.box ? "box" : "ford",
sameSlopes: slopes(plain) === slopes(legacy),
wallSpans: legacy.wallSpans ?? null,
shoulderSpans: legacy.shoulderSpans ?? null,
};
});
}
process.stdout.write(JSON.stringify(results));
@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
"""PLAN 115-4 — B05 3D 옛 방식 A(08-25 `6e87dd6cd` 실루엣 갈아 끼우기) 별개 단추.
Node 도우미(helper_115_4_corridor_legacy.cjs)가 TS 를 트랜스파일해 픽스처 측점으로 돌리고,
여기서는 결과(JSON)를 단언한다. 배수관 세트 측 성토 리본이 직선이 아니라 실루엣을 따름 ·
스케치 · 자르기 · 패치 없음 · BOX · 세월교는 원본 비탈 · 기본 빌드 그대로 · 설정 키 등록.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = Path(HERE).parents[1]
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_115_4_corridor_legacy.cjs")],
capture_output=True,
text=True,
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_drainage_set_fill_follows_silhouette(results):
r = results["set"]
assert r["plainBend"] < 0.05 # 기본 = 직선 성토 비탈(1:1.2 꼴)
assert 1.1 < r["plainRatio"] < 1.4
assert r["legacyBend"] > 0.5 # 옛 방식 A = 벽 상단 · 전면 · 성토부선 실루엣
assert r["wallSpans"] == ["left", "right"]
assert r["plainWallSpans"] is None
assert r["indexArg"] is True # `.map(classifyStation)` 인덱스는 끔
def test_legacy_build_has_no_sketch_cut_patch(results):
r = results["build"]
assert r["keys"] == ["caps", "outline", "ribbons", "structures"] # planCurves · cutWalls 없음
assert r["patches"] == 0
assert r["structures"] > 0 # 솔리드는 지금 것 그대로 얹음
assert r["rowBend"] > 0.5 # 측점 행 성토 리본이 실루엣 따름
assert r["baseRowBend"] < 0.05
assert r["basePlanCurves"] is True and r["baseCutWalls"] is True # 기본 빌드 그대로
def test_box_and_ford_keep_original_slopes(results):
kinds = {item["kind"]: item for item in results["others"]}
assert set(kinds) == {"box", "ford"}
for item in kinds.values():
assert item["sameSlopes"] is True
assert item["wallSpans"] is None
assert item["shoulderSpans"] is None # 노견 확장 상시 적용(08-25 ①)도 그날 전
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