feat(B05): 3D 새 방식 구조물 마스크 — 풋프린트 · 아령 외곽 구멍 · 날개 안쪽 성토 금지 · 구간 단면 상부 포락선 + 이웃 측점 테이퍼(115-2)

- 구조물 구간 단면 = 소유 측점 실루엣 상부 포락선(앵커 기준) · 구간 안 고정 · 끝 → 이웃 측점 테이퍼
- BOX(구조물에서 끝나는 실루엣) 구간은 단면 끝 너머 연장 없음 = 본체 밖 원지반
- 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(솔리드가 메움 · 솔리드 코드는 그대로)
- 날개 안쪽 유로 · 에이프런 성토 금지 고리 — 원지반(세월교는 바닥판 상단)까지만

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:20:45 +09:00
co-authored by Claude Opus 5.5
parent 01c2a5983b
commit a92e1768ca
5 changed files with 562 additions and 14 deletions
@@ -243,4 +243,182 @@ const results = {};
};
}
// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다.
{
const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const env = upperEnvelope(
[
[0, 0],
[2, 0],
[2.1, -3],
[1.8, -3.2],
[5, -4],
].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
);
const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m;
results.envelope = {
monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m),
at18: r4(at(1.8)),
at21: r4(at(2.1)),
at5: r4(at(5)),
};
}
/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */
function slopeOf(points, opts = {}) {
const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
return {
chainage_m: 20,
side: "left",
kind: "fill",
span: { beforeM: 2, afterM: 2 },
points: resample(
points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
48,
),
endsAtStructure: false,
...opts,
};
}
// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5).
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const shelf = slopeOf([
[0, 0],
[4, 0],
[6, -4],
]);
const source = deform.stationProfileSource(sections, [shelf]);
const field = deform.buildDeformField(build, source, () => 96);
results.span = {
in20: r4(field.deformZ(20, HALF + 2, 96)),
in19: r4(field.deformZ(19, HALF + 2, 96)),
taper10: r4(field.deformZ(10, HALF + 2, 96)),
taper30: r4(field.deformZ(30, HALF + 2, 96)),
right20: r4(field.deformZ(20, -HALF - 2, 96)),
};
}
// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const box = slopeOf(
[
[0, 0],
[3, -1],
],
{ endsAtStructure: true },
);
const field = deform.buildDeformField(
build,
deform.stationProfileSource(sections, [box]),
() => 96,
);
results.box = {
inside: r4(field.deformZ(20, HALF + 2, 96)),
outside: r4(field.deformZ(20, HALF + 5, 96)),
normal: r4(field.deformZ(5, HALF + 5, 96)),
};
}
// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const source = deform.stationProfileSource(sections);
const loop = [
[8, 4],
[12, 4],
[12, 8],
[8, 8],
];
const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]);
const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]);
const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 });
const wings = [
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] },
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] },
];
const limits = noFillLimits(sections, wings);
results.noFill = {
blocked: r4(none.deformZ(10, HALF + 2, 96)),
ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)),
outsideLoop: r4(none.deformZ(20, HALF + 2, 96)),
limits: limits.length,
hullArea: limits.length ? limits[0].loop.length : 0,
};
}
// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
build.sections = sections;
const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 });
build.structures = [
{
chainage_m: 20,
kind: "revet",
polygon: [
[5, 95],
[6, 95],
[6, 100],
[5, 100],
],
rings: [ring(19), ring(20), ring(21)],
},
];
const step = 0.5;
const cx = 20;
const cz = 100;
const positions = [];
const nx = 121;
const ny = 81;
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step));
}
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.Float32BufferAttribute(positions, 3));
geometry.setIndex(index);
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial());
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
const out = deformTerrain(
split,
build,
{ x: [-10, 50], y: [-20, 20], z: [90, 110] },
new TerrainHeightIndex(terrain),
);
let inHole = 0;
let nearHole = 0;
out.root.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
const p = child.geometry.getAttribute("position");
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));
}
sx /= 3;
sy /= 3;
if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1;
if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1;
}
});
results.hole = { holes: out.stats.holes, inHole, nearHole };
}
process.stdout.write(JSON.stringify(results));
@@ -74,3 +74,41 @@ def test_three_copy_only_xy_fixed_hole_and_tint(results):
assert r["probe"] == FILL_2M
assert r["moved"] > 0 and r["raised"] == r["moved"]
assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]
def test_upper_envelope_keeps_top_of_wall_steps(results):
r = results["envelope"]
assert r["monotone"] is True
assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0)
assert r["at21"] == -3
assert r["at5"] == -4
def test_structure_span_fixed_and_tapers_to_neighbours(results):
r = results["span"]
assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정
assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼
assert r["taper30"] == r["taper10"]
assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로
def test_box_outside_body_stays_original_ground(results):
r = results["box"]
assert r["inside"] == round(100 - 2 / 3, 4)
assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함)
assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형
def test_no_fill_ring_inside_wings(results):
r = results["noFill"]
assert r["blocked"] == 96
assert r["ceiling"] == 97
assert r["outsideLoop"] == FILL_2M
assert r["limits"] == 1 and r["hullArea"] == 4
def test_structure_footprint_hole(results):
r = results["hole"]
assert r["holes"] == 1
assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음
assert r["nearHole"] > 0 # 바로 바깥은 남는다