fix(abut-rebar): 날개벽 철근이 콘크리트 밖으로 나가는 곳 수정

- WR12 (전면 등단면) 아래 갈고리: 전면이 저판 끝면과 같은 면이라 바깥으로 꺾으면 저판 밖 → 안쪽으로
- WR7-1 · 7-2 · 7-3 · WR10 날개벽 다리: 뒷굽 끝 너머 경사 하단이 올라오는 곳에서 c 안쪽까지만 (최대 l_d)
- WR20: 갈고리까지 날개벽 안인 단만
- 테스트: 교대 A1 모든 3D 철근을 ~100 mm 간격으로 찍어 콘크리트 안인지 확인 (P10은 슬래브 쪽 절반만 밖)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 18:12:10 +09:00
1 parent e3c9ad0953
commit 81fe1f1549
3 files changed
+179 -6

No files matched your search

+16 -6
View File
@@ -241,7 +241,8 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
const sp = face === 'front' ? 2 * sBack : sBack;
const off = face === 'front' ? 0 : o;
const depth = face === 'front' ? c : T - c;
const out = face === 'front' ? -1 : 1; // 갈고리는 날개벽 바깥쪽
// 갈고리 방향: 전면은 저판 끝면과 같은 면이라 바깥으로 꺾으면 콘크리트 밖 → 두 면 모두 +n (전면은 날개벽 안쪽, 배면은 흙 쪽 저판 안).
const out = 1;
const heelF = Math.min(g.heelD, g.L) + off;
const endF = g.L + off;
const base = heelF - c;
@@ -357,6 +358,13 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
const add2 = (p: V2, u: V2, f: number): V2 => [p[0] + u[0] * f, p[1] + u[1] * f];
const haunchLevels = { 'WR7-1': 0, 'WR7-2': 0, 'WR7-3': 0 };
const wDir: V2 = [ws, wt];
// 날개벽 안쪽 다리 (WR7 · WR10): 경사 하단이 올라오는 곳에서 c 안쪽까지만, 최대 l_d.
const legEnd = (A: V2, z: number) => {
const dA = (A[0] - g.p0[0]) * ws + (A[1] - g.p0[1]) * wt;
let lim = g.L - c;
if (general && z - c < g.bottomEnd) lim = Math.min(lim, g.heelD + ((z - c - g.footTop) / (g.bottomEnd - g.footTop)) * (g.L - g.heelD));
return Math.max(0, Math.min(ld.H25, lim - dA));
};
if (g.haunch > 0) {
const sp = 2 * sBackV;
const legIn = T - c - (d('H16') + d('H25')) / 2 - (d('H25') + d('H25')) / 2; // WR3–5 안쪽 25.4
@@ -382,9 +390,10 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
const legLine = at(0, legIn);
const A = lineX(legLine, wDir, diagP, du);
const B = lineX(diagP, du, [0, backT + c], [1, 0]);
const P0 = add2(A, wDir, ld.H25);
const leg = legEnd(A, z);
const P0 = add2(A, wDir, leg);
const C: V2 = [B[0], tEnd];
const len = ld.H25 + Math.hypot(B[0] - A[0], B[1] - A[1]) + Math.abs(tEnd - B[1]) + h25;
const len = leg + Math.hypot(B[0] - A[0], B[1] - A[1]) + Math.abs(tEnd - B[1]) + h25;
put(mark, [P3(P0, z), P3(A, z), P3(B, z), P3(C, z), P3(C, z - h25)], len);
}
// WR9 (§6, 결정 2026-11-04): 아래 헌치 대각 위 연직 @2·s_h, 개수 = floor(대각 / 간격) + 1, 가운데 맞춤. 위 끝 브라켓 상면 − c (그 위는 헌치 없음).
@@ -414,7 +423,8 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
const X = lineX(wingLine, wDir, [0, backT + depth10], [1, 0]);
const X1 = add2(X, uOut, r);
const X2 = add2(X, wDir, r);
put('WR10', [P3(add2(X1, uOut, ld.H25), z), P3(X1, z), P3(X2, z), P3(add2(X2, wDir, ld.H25), z)], 2 * ld.H25 + Math.hypot(X2[0] - X1[0], X2[1] - X1[1]));
const leg = legEnd(X2, z);
put('WR10', [P3(add2(X1, uOut, ld.H25), z), P3(X1, z), P3(X2, z), P3(add2(X2, wDir, leg), z)], ld.H25 + leg + Math.hypot(X2[0] - X1[0], X2[1] - X1[1]));
}
}
// WR20 (§8): 간격재, 수평 @k·s_h × 연직 @k·s_v, 단마다 반 칸 엇갈림, 전면 · 배면 수평철근 사이 + 양끝 갈고리.
@@ -427,7 +437,7 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
let j = 0;
for (let z = g.footTop + c; z <= g.topStart + g.slope * g.L - c + 1e-6; z += dz, j++) {
for (let dd = g.wallBackD + c + (j % 2 ? dx / 2 : 0); dd <= g.L - c + 1e-6; dd += dx) {
if (z > top(dd) - c || z < bottom(dd) + c) continue;
if (z > top(dd) - c || z - h13 < bottom(dd) + c) continue; // 갈고리까지 날개벽 안
const a = at(dd, dF);
const b = at(dd, dB);
put('WR20', [P3(a, z - h13), P3(a, z), P3(b, z), P3(b, z - h13)], dB - dF + 2 * h13);
@@ -452,7 +462,7 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial<AbutWingRebarSe
const hz2 = lenText(2 * sBackV);
const hz1 = lenText(sBackV);
push({ mark: 'WR11', size: SZ.WR11, role: '연직', spacing: v2, actual: v2, shape: '직선 · 아래 경사 하단 + c', position: '전면 · 변화단면', note: `기준선 (뒷굽 끝 − ${lenText(c)}) → 끝 쪽 ${vertical.front.nV}@${v2} · 끝 여유 ${lenText(vertical.front.endGap)}`, basis: '규칙', modified: vMod });
push({ mark: 'WR12', size: SZ.WR12, role: '연직', spacing: v2, actual: v2, shape: `아래 90° 갈고리 ${h16} (저판 B6 · B8 위)`, position: '전면 · 등단면', note: `기준선 + 교대 쪽 ${vertical.front.nE}@${v2} · 마지막 ${lenText(vertical.front.lastE)}`, basis: '규칙', modified: vMod });
push({ mark: 'WR12', size: SZ.WR12, role: '연직', spacing: v2, actual: v2, shape: `아래 90° 갈고리 ${h16} (저판 B6 · B8 위, 안쪽으로)`, position: '전면 · 등단면', note: `기준선 + 교대 쪽 ${vertical.front.nE}@${v2} · 마지막 ${lenText(vertical.front.lastE)} · 전면은 저판 끝면과 같은 면이라 갈고리를 안쪽으로`, basis: '규칙', modified: vMod });
push({ mark: 'WR1', size: SZ.WR1, role: '연직', spacing: v1, actual: v1, shape: '직선 · 아래 경사 하단 + c', position: '배면 · 변화단면', note: `${vertical.back.nV}@${v1} · 끝 여유 ${lenText(vertical.back.endGap)}`, basis: '규칙', modified: vMod });
push({ mark: 'WR2', size: SZ.WR2, role: '연직', spacing: v1, actual: v1, shape: `아래 90° 갈고리 ${h16}`, position: '배면 · 등단면', note: `기준선 + ${vertical.back.nE}@${v1} · 마지막 ${lenText(vertical.back.lastE)}`, basis: '규칙', modified: vMod });
push({ mark: 'WR13', size: SZ.WR13, role: '수평', spacing: hz2, actual: hz2, shape: `교대 쪽 흉벽 지나 90° 갈고리 ${h25} · 맨 위 2단 경사와 나란히`, position: '전면 수평 · 등단면 띠', note: '흉벽 전면 − c까지 (Q-WR1: 맨 위 2단 경사)', basis: '규칙', modified: hMod });
+87
View File
@@ -0,0 +1,87 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import * as XLSX from 'xlsx';
import { describe, expect, it } from 'vitest';
import { buildAbut } from '../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
import { buildMembers } from '../src/core/abut/members';
import { buildWings } from '../src/core/abut/wings';
import { resolveSlab } from '../src/core/abut/slab';
import { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing';
import { skewAngle } from '../src/core/abut/rebar/skew-layout';
import { abutWallGeom, abutWallRebar } from '../src/core/abut/rebar/wall';
import { abutParapetGeom, abutParapetRebar, slabSpanAt } from '../src/core/abut/rebar/parapet';
import { abutWingGeom, abutWingRebar } from '../src/core/abut/rebar/wing';
import { abutPlanOutline, abutSkewK, buildAbutGroup } from '../src/view/abut-scene';
import { buildAbutRebar3D } from '../src/view/abut-rebar3d';
// 교대 A1: 모든 3D 철근이 콘크리트(저판 · 벽체 · 흉벽 · 브라켓 · 날개벽 · 헌치) 안에 있는지 (2026-11-04, 화면에서 날개벽 WR12 갈고리 ·
// WR7-3 · WR10 다리가 밖으로 나간 것을 고친 뒤). 철근 축을 ~100 mm 간격으로 찍고 메시 안팎은 광선 교차 수 홀짝으로 본다.
// P10 다웰은 접속슬래브로 300 올라가므로 위 절반만 밖에 있어도 된다.
const res: Record<string, { out: number; total: number }> = {};
{
const xlsx = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(xlsx), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const wings = buildWings(model, members);
const angle = skewAngle(rec.skewDeg);
const fg = abutFootingGeom(model.length, members.shape, angle);
const footing = abutFootingRebar(fg);
const wg = abutWallGeom(model.length, members, angle);
const wall = abutWallRebar(wg, footing);
const slab = resolveSlab();
const k = abutSkewK(model);
const tM = (wg.xb / 1000 + members.shape.parapetBackM) / 2;
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, k)!;
const pg = abutParapetGeom(wg, members.shape, model.bearings[0].s, { B: model.wing!.slabWidthMm, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: 300, chamferB: 300 });
const para = abutParapetRebar(pg, wall);
const wingR = wings.map((w) => abutWingRebar(abutWingGeom(model, members, w, footing.z.B68)));
const rebar = buildAbutRebar3D(fg, footing, k, members.shape.footingBackM, members.footingBottomEl!, wall, para, wingR);
const group = buildAbutGroup(model, members, wings);
group.updateMatrixWorld(true);
const solids: THREE.Mesh[] = [];
group.traverse((o) => {
if (o instanceof THREE.Mesh && !['ground', 'slab', 'blinding', 'piles'].some((n) => o.parent?.name === n || o.name === n)) solids.push(o);
});
const ray = new THREE.Raycaster();
const inside = (p: THREE.Vector3) =>
solids.some((m) => {
ray.set(p, new THREE.Vector3(0.577, 0.577, 0.577));
return ray.intersectObject(m, false).length % 2 === 1;
});
for (const [name, meshes] of rebar.byName) {
for (const mesh of meshes) {
const geo = mesh.geometry as THREE.CylinderGeometry;
geo.computeBoundingBox();
// Cylinder axis: the two cap centres = bbox centre ± half length along its direction; sample the bbox diagonal's centre line instead.
const pos = geo.getAttribute('position');
const a = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0));
const b = new THREE.Vector3(pos.getX(pos.count - 1), pos.getY(pos.count - 1), pos.getZ(pos.count - 1));
const n = Math.max(2, Math.ceil(a.distanceTo(b) / 0.1));
const r = (res[name] ??= { out: 0, total: 0 });
for (let i = 0; i <= n; i++) {
const p = a.clone().lerp(b, i / n);
r.total++;
if (!inside(p)) {
r.out++;
}
}
}
}
}
describe('교대 A1 3D 철근이 콘크리트 안에 있음', () => {
it('저판 · 벽체 · 흉벽 · 날개벽 철근은 밖으로 나간 점이 없음 (P10 제외)', () => {
const outside = Object.entries(res).filter(([name, v]) => name !== 'P10' && v.out > 0).map(([name, v]) => `${name} ${v.out}/${v.total}`);
expect(outside).toEqual([]);
expect(Object.keys(res).length).toBeGreaterThan(30);
});
it('P10 다웰: 슬래브 쪽 절반만 밖 (브라켓 안 300은 콘크리트 안)', () => {
expect(res.P10.out / res.P10.total).toBeLessThan(0.6);
expect(res.P10.out / res.P10.total).toBeGreaterThan(0.4);
});
});
+76
View File
@@ -0,0 +1,76 @@
// 교대 A1 3D 철근 중 콘크리트 밖으로 나간 부분을 찾는다: 철근 축을 ~100 mm 간격으로 찍어 콘크리트 메시(저판 · 벽체 · 흉벽 · 날개벽 ·
// 헌치 · 받침블럭) 중 하나라도 안에 드는지 광선 교차 수의 홀짝으로 본다. 결과 JSON = 철근 번호별 밖 점 수와 예.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import * as XLSX from 'xlsx';
import { it } from 'vitest';
import { buildAbut } from '../../src/core/abut/geometry';
import { parseAbutments, toAbutInput } from '../../src/core/abut/parse';
import { buildMembers } from '../../src/core/abut/members';
import { buildWings } from '../../src/core/abut/wings';
import { resolveSlab } from '../../src/core/abut/slab';
import { abutFootingGeom, abutFootingRebar } from '../../src/core/abut/rebar/footing';
import { skewAngle } from '../../src/core/abut/rebar/skew-layout';
import { abutWallGeom, abutWallRebar } from '../../src/core/abut/rebar/wall';
import { abutParapetGeom, abutParapetRebar, slabSpanAt } from '../../src/core/abut/rebar/parapet';
import { abutWingGeom, abutWingRebar } from '../../src/core/abut/rebar/wing';
import { abutPlanOutline, abutSkewK, buildAbutGroup } from '../../src/view/abut-scene';
import { buildAbutRebar3D } from '../../src/view/abut-rebar3d';
it('dump 콘크리트 밖 철근', () => {
const out = process.env.DUMP_OUT ?? fileURLToPath(new URL('./out', import.meta.url));
mkdirSync(out, { recursive: true });
const xlsx = fileURLToPath(new URL('../../../../RAG/운곡교P1입력.xlsx', import.meta.url));
const rec = parseAbutments(XLSX.read(readFileSync(xlsx), { type: 'buffer' })).abutments[0];
const model = buildAbut(toAbutInput(rec));
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
const wings = buildWings(model, members);
const angle = skewAngle(rec.skewDeg);
const fg = abutFootingGeom(model.length, members.shape, angle);
const footing = abutFootingRebar(fg);
const wg = abutWallGeom(model.length, members, angle);
const wall = abutWallRebar(wg, footing);
const slab = resolveSlab();
const k = abutSkewK(model);
const tM = (wg.xb / 1000 + members.shape.parapetBackM) / 2;
const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, k)!;
const pg = abutParapetGeom(wg, members.shape, model.bearings[0].s, { B: model.wing!.slabWidthMm, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: 300, chamferB: 300 });
const para = abutParapetRebar(pg, wall);
const wingR = wings.map((w) => abutWingRebar(abutWingGeom(model, members, w, footing.z.B68)));
const rebar = buildAbutRebar3D(fg, footing, k, members.shape.footingBackM, members.footingBottomEl!, wall, para, wingR);
const group = buildAbutGroup(model, members, wings);
group.updateMatrixWorld(true);
const solids: THREE.Mesh[] = [];
group.traverse((o) => {
if (o instanceof THREE.Mesh && !['ground', 'slab', 'blinding', 'piles'].some((n) => o.parent?.name === n || o.name === n)) solids.push(o);
});
const ray = new THREE.Raycaster();
const inside = (p: THREE.Vector3) =>
solids.some((m) => {
ray.set(p, new THREE.Vector3(0.577, 0.577, 0.577));
return ray.intersectObject(m, false).length % 2 === 1;
});
const res: Record<string, { out: number; total: number; ex: number[][] }> = {};
for (const [name, meshes] of rebar.byName) {
for (const mesh of meshes) {
const geo = mesh.geometry as THREE.CylinderGeometry;
geo.computeBoundingBox();
// Cylinder axis: the two cap centres = bbox centre ± half length along its direction; sample the bbox diagonal's centre line instead.
const pos = geo.getAttribute('position');
const a = new THREE.Vector3(pos.getX(0), pos.getY(0), pos.getZ(0));
const b = new THREE.Vector3(pos.getX(pos.count - 1), pos.getY(pos.count - 1), pos.getZ(pos.count - 1));
const n = Math.max(2, Math.ceil(a.distanceTo(b) / 0.1));
const r = (res[name] ??= { out: 0, total: 0, ex: [] });
for (let i = 0; i <= n; i++) {
const p = a.clone().lerp(b, i / n);
r.total++;
if (!inside(p)) {
r.out++;
if (r.ex.length < 3) r.ex.push([p.x, p.y, p.z].map((v) => Math.round(v * 1000) / 1000));
}
}
}
}
writeFileSync(`${out}/outside.json`, JSON.stringify(Object.fromEntries(Object.entries(res).filter(([, v]) => v.out > 0)), null, 1));
});