Files
Aislo/resources/tester/helper_135_3_chute_solid.cjs
T
eomsangdonandClaude Opus 5.5 1c112d3b3c feat(b05): 3D 도수로 · 돌붙임 띠 솔리드 — 유출 성토 조각 스윕 · 길이 = 유출 구조물 폭 · 해시 키 · ③ 구멍 안 뚫림(135-3)
- 단면 = B06 횡단 띠와 한 벌(Wall_Hatch chuteBandOutline 로 떼어 냄 · appendChuteBand 가 같이 씀)
- 관 = 유출 벽 길이 · BOX = 구체 폭(축 고정) · 세월교 = 월류 폭 · 물넘이 포장(ford_chute) = 포장 폭 · 주인 측점 하나
- 구조물 종류 chute · 색 · 끝 마구리 = 마디마다 사다리꼴 · 해시 = 고른 때만 ch 키(안 고른 측점 저장본 그대로)
- ③ structureTopLoops 가 chute 를 뺌
- 새 시험 test_135_3_chute_solid(+ 도우미)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-09 15:09:17 +09:00

271 lines
10 KiB
JavaScript

/* PLAN 135-3 — B05 3D 도수로 · 돌붙임 띠 솔리드 헬퍼. 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* 입력 = 73-4 픽스처(박스 352.14 · 세월교 173.087 · 일반 180) · 결과 = 표준출력 JSON. 짝 = test_135_3_chute_solid.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 cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
three: require(path.join(ROOT, "config", "node_modules", "three")),
};
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
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,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const B05 = (f) => path.join(ROOT, "B05_Profile", `${f}.ts`);
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const { buildCorridorStructures } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const { structureHashParts } = loadTs(B05("B05_Profile_UI_Corridor_Structures_Hash"));
const { structureTopLoops } = loadTs(B05("B05_Profile_UI_Corridor_Tin_Design"));
const { chuteBandOutline } = loadTs(B06("B06_Section_UI_Cross_Wall_Hatch"));
const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom"));
const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford"));
const { outletFillLine } = loadTs(B06("B06_Section_UI_Cross_Ford_Pavement"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const clone = (v) => JSON.parse(JSON.stringify(v));
const at = (c) => clone(fixture.cross_sections.find((s) => Math.abs(s.chainage_m - c) < 0.6));
const CHUTE = { structure: "돌붙임", thickness_m: 0.3 };
/** 원지반을 내려 유출 쪽이 성토가 되게 한다(픽스처는 절토 측점). */
const lower = (s, dz) => {
s.samples.forEach((p) => {
p.z -= dz;
p.elevation_m -= dz;
});
return s;
};
/** 직선 노선 — 가장 가까운 측점 프레임에서 앞쪽으로 편다. */
const frameAtOf = (sections) => (atM) => {
const s = sections.reduce((b, x) =>
Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b,
);
const [lx, ly] = s.frame.left_xy;
const along = atM - s.chainage_m;
return {
cx: s.center_x - ly * along,
cy: s.center_y + lx * along,
leftX: lx,
leftY: ly,
designZ: s.design?.design_elevation_m ?? null,
};
};
const chutesOf = (sections) =>
buildCorridorStructures(sections, frameAtOf(sections)).filter((s) => s.kind === "chute");
const lengthOf = (solid) => {
const a = solid.rings[0];
const b = solid.rings[solid.rings.length - 1];
return Math.round(Math.hypot(b.cx - a.cx, b.cy - a.cy) * 1000) / 1000;
};
const flat = (pts) => pts.map((p) => [p.offset, p.elevation]);
const same = (a, b) =>
a.length === b.length &&
a.every(([o, e], i) => Math.abs(o - b[i][0]) + Math.abs(e - b[i][1]) < 1e-9);
const out = {};
function check(name, section, expectedBands, expectedLength, strip) {
const without = clone(section);
strip(without);
const withChute = section;
const solids = chutesOf([withChute]);
const all = buildCorridorStructures([withChute], frameAtOf([withChute]));
const allWithout = buildCorridorStructures([without], frameAtOf([without]));
out[name] = {
count: solids.length,
expected: expectedBands.length,
sameShape: solids.every((s, i) => expectedBands[i] && same(s.polygon, flat(expectedBands[i]))),
keys: [...new Set(solids.map((s) => s.key))],
lengths: solids.map(lengthOf),
expectedLength,
holesSame: structureTopLoops(all).length === structureTopLoops(allWithout).length,
otherSolidsSame: all.length - solids.length === allWithout.length,
};
}
// ① BOX(흙막이 없는) — 유출 날개 쪽 성토 조각 · 길이 = 구체 폭(span_m)
{
const s = lower(at(352.14), 15);
delete s.culvert;
const plain = clone(s);
s.box.chute = CHUTE;
const layout = computeBoxLayout(s, s.samples);
const bands = layout.sides
.filter((side) => side.wingRole === "outlet")
.flatMap((side) => side.fillSegments.filter((g) => g.kind !== "cut"))
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
// 해시 — 고르면 바뀌고 · 안 고르면 그대로(빈 칸을 안 붙임)
out.hash = {
changed: structureHashParts(s).join("|") !== structureHashParts(plain).join("|"),
plainNoChute: !structureHashParts(plain).includes("ch"),
};
// 비교용 원본은 chute 없는 꼴로
check("box", s, bands, s.box.span_m, (x) => delete x.box.chute);
out.box.noChuteNoSolid = chutesOf([plain]).length === 0;
}
// ② 세월교(흙막이 없는) — 유출 측벽 성토 조각 · 길이 = 월류 폭(span_m)
{
const s = lower(at(173.087), 15);
delete s.culvert;
s.ford.chute = CHUTE;
const stored = s.design?.ford_adjust;
const layout = computeFordLayout(s, s.samples, {
inlet: { heightM: null, lateralM: 0, slopeM: 0, ...(stored?.inlet ?? {}) },
outlet: { heightM: null, lateralM: 0, slopeM: 0, ...(stored?.outlet ?? {}) },
});
const bands = layout.sides
.filter((side) => side.role === "outlet")
.flatMap((side) => side.fillSegments.filter((g) => g.kind !== "cut"))
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
check("ford", s, bands, Math.max(s.ford.span_m, 0.5), (x) => delete x.ford.chute);
}
// ③ 물넘이 포장(136-2 ford_chute) — 유출측 성토사면 선 · 길이 = 포장 폭 · 주인 측점 하나만
{
const s = at(180);
s.ford_pavement = { chainage_m: 180, span_m: 4, depth_m: 0.3, slope_pct: null, chute: CHUTE };
const band = chuteBandOutline(
outletFillLine(s.design).map((p) => p),
0.3,
)?.outline;
check("pave", s, band ? [band] : [], 4, (x) => delete x.ford_pavement.chute);
const neighbour = at(160);
neighbour.ford_pavement = clone(s.ford_pavement);
const pair = [neighbour, s];
out.pave.ownerOnly = chutesOf(pair).length === out.pave.count;
}
// ④ 관(배수관 세트) — 유출 성토 조각(단 사이 포함) · 길이 = 유출 벽 길이(전 5 + 후 5)
{
const s = lower(at(352.14), 15);
delete s.box;
delete s.culvert.body;
s.culvert.hidden_pipe = false;
s.culvert.chute = CHUTE;
const { culvertLayoutOf } = loadTs(B05("B05_Profile_UI_Corridor_Structures"));
const layout = culvertLayoutOf(s);
const bands = (layout?.outletFill.segments ?? [])
.filter((g) => g.kind !== "cut")
.map((g) => chuteBandOutline(g.points, 0.3)?.outline)
.filter(Boolean);
const spec = s.culvert.outlet;
check(
"pipe",
s,
bands,
(spec.revet_before_m ?? 5) + (spec.revet_after_m ?? 5),
(x) => delete x.culvert.chute,
);
}
// ⑥ 3D 메시 — 띠 끝 마구리 = 마디마다 사다리꼴(부채꼴 아님) · 색 있음
{
const s = lower(at(352.14), 15);
delete s.box;
delete s.culvert.body;
s.culvert.hidden_pipe = false;
s.culvert.chute = CHUTE;
const solid = chutesOf([s])[0];
// 꺾인 띠(점 셋) 로 갈아 끼워 마구리 삼각형 수를 센다
solid.polygon = chuteBandOutline(
[
{ offset: -6, elevation: 832 },
{ offset: -9, elevation: 830 },
{ offset: -12, elevation: 830 },
],
0.3,
).outline.map((p) => [p.offset, p.elevation]);
const { createCorridorGroup } = loadTs(B05("B05_Profile_UI_Corridor_Mesh"));
const group = createCorridorGroup(
{ ribbons: [], outline: { chainages: [], left: [], right: [] }, caps: [], structures: [solid] },
{
x: [s.center_x - 100, s.center_x + 100],
y: [s.center_y - 100, s.center_y + 100],
z: [800, 900],
},
);
const mesh = group.children.find((c) => c.name.startsWith("corridor-structure:chute"));
const count = solid.polygon.length;
const rings = solid.rings.length;
// 마구리 삼각형 넓이 합(부호 없음) = 띠 넓이 × 2 — 부채꼴이면 오목한 자리에서 넘침
const pos = mesh.geometry.getAttribute("position");
const capTris = 4 * (count / 2 - 1);
let capArea = 0;
for (let t = 0; t < capTris; t += 1) {
const v = [0, 1, 2].map((k) => [pos.getX(t * 3 + k), pos.getY(t * 3 + k), pos.getZ(t * 3 + k)]);
const u = v[1].map((x, k) => x - v[0][k]);
const w = v[2].map((x, k) => x - v[0][k]);
const c = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
capArea += Math.hypot(...c) / 2;
}
let bandArea = 0;
solid.polygon.forEach(([x1, y1], i) => {
const [x2, y2] = solid.polygon[(i + 1) % count];
bandArea += x1 * y2 - x2 * y1;
});
out.mesh = {
capAreaRatio: Math.round((capArea / Math.abs(bandArea)) * 1000) / 1000,
found: Boolean(mesh),
colored: Boolean(mesh?.material?.color),
triangles: mesh ? mesh.geometry.getAttribute("position").count / 3 : 0,
expected: 4 * (count / 2 - 1) + 2 * count * (rings - 1),
};
}
// ⑤ ③ 구멍 — 띠 윗면이 평평한 자리(단 사이 수평)여도 구멍 고리를 안 냄 · 같은 꼴 벽이면 냄
{
const s = lower(at(352.14), 15);
delete s.culvert;
s.box.chute = CHUTE;
const flatBand = chuteBandOutline(
[
{ offset: -8, elevation: 830 },
{ offset: -12, elevation: 830 },
],
0.3,
).outline.map((p) => [p.offset, p.elevation]);
const flat = chutesOf([s]).map((x) => ({ ...x, polygon: flatBand }));
out.flatTop = {
asWall: structureTopLoops(flat.map((x) => ({ ...x, kind: "revet" }))).length,
asChute: structureTopLoops(flat).length,
};
}
process.stdout.write(JSON.stringify(out));