diff --git a/src/core/abut/rebar/wing.ts b/src/core/abut/rebar/wing.ts index 3ad481d..9f56e97 100644 --- a/src/core/abut/rebar/wing.ts +++ b/src/core/abut/rebar/wing.ts @@ -241,7 +241,8 @@ export function abutWingRebar(g: AbutWingGeom, changes?: Partial [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 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 = {}; +{ + 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); + }); +}); diff --git a/verify/dump/abut-rebar-outside.dump.ts b/verify/dump/abut-rebar-outside.dump.ts new file mode 100644 index 0000000..97be977 --- /dev/null +++ b/verify/dump/abut-rebar-outside.dump.ts @@ -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 = {}; + 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)); +});