v4: 평면·단면 기반 통합모델 생성기와 뷰어 추가
계획도 블록 중심 보정과 A-A 단면 표고(말뚝 6그룹, 띠장, 굴착 바닥, 도로면)를 반영해 엄지말뚝·토류판·띠장·수직 PIECE·버팀보·스크류잭·채널·중간말뚝을 배치한다. 뷰어에 부재별 온오프·투명도, 거리 측정, 시점 전환, 부드러운 휠 줌을 추가한다. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
785b673c18
commit
d6abe6707a
4 files changed
+9141
No files matched your search
+20
@@ -230,6 +230,26 @@ C:\Users\User\.claude\projects\d--Obsidian-Vault-999ETC-261016_HW_DwfTo3D\memory
|
||||
- MOCK-TESTING.md 예상 결과물 & 시뮬레이션 정의
|
||||
- 배포 전 모든 준비 완료
|
||||
|
||||
### 2026-10-20~21 (v4 통합모델 - 평면·단면 기반)
|
||||
- 생성기: `src/build/generate-v4.mjs` → `out/gasiseol-v4-stats.json`
|
||||
- 뷰어: `out/viewer-v4.html` (부재별 온오프·투명도, 거리 측정, 정면/평면/측면/등각 시점, 부드러운 휠 줌)
|
||||
- 기준: 평면도 엄지말뚝 시작점(-1248.43, -2617.94), 블록 중심 보정(INSERT + 회전·블록 중심 오프셋)
|
||||
- 단면: A-A에서 말뚝 상단 6그룹(0.3m 계단, 길이 8.5m), 띠장 2단, 굴착 바닥(녹색 점선 최저), 도로면(녹색 점선 최고)
|
||||
- 부재 구성: 엄지말뚝 122, 토류판 120, 띠장 48, 수직 PIECE 52, 버팀보 26, 스크류잭 26, 채널 12, 중간말뚝 13 (총 419)
|
||||
- 간섭: 박스 근사 검사 기준 엄지말뚝↔토류판만 남음 (H형강 단면 기준으로는 겹치지 않음)
|
||||
|
||||
**열린 가정 (확인 필요)**
|
||||
- 도로면 폭 10m (도면 미기재)
|
||||
- 토류판 높이 = 말뚝 길이 8.5m
|
||||
- 스크류잭 폭 0.3m, 끝판 두께는 상세도 치수 기준
|
||||
- 채널 길이 = 중간말뚝 묶음 범위 (말뚝 Y 묶음 기준 분할)
|
||||
- 버팀보 X 간격: 도면 치수 "11@5.000" 및 4m 치수 기준 (13지점)
|
||||
- 벽체 방향 대응: A-A 단면 X ↔ 계획도 X 선형 대응, 말뚝 높은 쪽은 X 큰 쪽
|
||||
|
||||
**다음 단계**
|
||||
- 검증 자동화 (수량, 간섭, 가정 목록)
|
||||
- 웹앱 이식 (src/web) 및 Vercel 배포
|
||||
|
||||
---
|
||||
|
||||
## 문의 및 피드백
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,343 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>가시설 통합모델 v4</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0d1f2d; font-family: 'Segoe UI', sans-serif; }
|
||||
canvas { display: block; width: 100%; height: 100%; }
|
||||
#panel { position: fixed; top: 10px; left: 10px; color: #fff; background: rgba(13,31,45,0.9); padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; min-width: 300px; }
|
||||
#panel h4 { margin: 0 0 4px; font-size: 14px; }
|
||||
#panel .row { display: flex; justify-content: space-between; gap: 10px; }
|
||||
#panel .spec { color: #9fb3c8; font-size: 11px; }
|
||||
#panel .vrow { display: flex; gap: 4px; margin-top: 6px; }
|
||||
#panel button.vb { margin-top: 0; width: auto; flex: 1; padding: 5px 4px; background: #1e3a4f; color: #fff; border: 1px solid #10b981; font-size: 12px; font-weight: 600; }
|
||||
#panel button.vb.on { background: #10b981; color: #0d1f2d; }
|
||||
#measureLabel { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background: rgba(13,31,45,0.92); color: #ffd166; padding: 8px 14px; border-radius: 6px; font-size: 14px; display: none; pointer-events: none; z-index: 5; }
|
||||
#panel button { margin-top: 8px; width: 100%; padding: 6px; background: #10b981; color: #0d1f2d; border: none; border-radius: 4px; font-weight: 700; cursor: pointer; }
|
||||
#hint { position: fixed; bottom: 8px; left: 10px; color: #9fb3c8; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="panel">
|
||||
<h4>부재 구성 (규격)</h4>
|
||||
<div id="counts">로딩 중...</div>
|
||||
<button id="btnFit">줌핏 (보이는 부재)</button>
|
||||
<div class="vrow">
|
||||
<button class="vb" data-view="front">정면</button>
|
||||
<button class="vb" data-view="top">평면</button>
|
||||
<button class="vb" data-view="side">측면</button>
|
||||
<button class="vb" data-view="iso">등각</button>
|
||||
</div>
|
||||
<div class="vrow">
|
||||
<button id="btnMeasure" class="vb">거리 측정: 끔</button>
|
||||
<button id="btnClear" class="vb">측정 지우기</button>
|
||||
</div>
|
||||
<label style="display:block;margin-top:6px;cursor:pointer"><input type="checkbox" id="chkExc" checked> 굴착면 표시 (벽체 내측면)</label>
|
||||
</div>
|
||||
<div id="hint">드래그: 회전 · 휠: 줌(커서 방향) · 우클릭 드래그: 이동</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const countsEl = document.getElementById('counts');
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0d1f2d);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.01, 100000);
|
||||
camera.up.set(0, 0, 1);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
sun.position.set(1, -1, 2);
|
||||
scene.add(sun);
|
||||
|
||||
const COLORS = { '엄지말뚝': 0x7ed321, '토류판': 0x4a90e2, '중간말뚝': 0xff6b6b, '수직 H300 (미확정)': 0xffd166, '버팀보': 0xf5a623, '띠장': 0x2dd4bf, '수직 PIECE': 0xa78bfa, '채널': 0xf472b6 };
|
||||
|
||||
// H형강 단면 (폭 w, 춤 h, 플랜지 tf, 웨브 tw). 축 방향 길이 L 로 압출, 중심은 원점
|
||||
function hSection(d) {
|
||||
const hw = d.w / 2, hh = d.h / 2, hwb = d.tw / 2;
|
||||
const s = new THREE.Shape();
|
||||
s.moveTo(-hw, -hh); s.lineTo(hw, -hh); s.lineTo(hw, -hh + d.tf);
|
||||
s.lineTo(hwb, -hh + d.tf); s.lineTo(hwb, hh - d.tf); s.lineTo(hw, hh - d.tf);
|
||||
s.lineTo(hw, hh); s.lineTo(-hw, hh); s.lineTo(-hw, hh - d.tf);
|
||||
s.lineTo(-hwb, hh - d.tf); s.lineTo(-hwb, -hh + d.tf); s.lineTo(-hw, -hh + d.tf);
|
||||
s.closePath();
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildGeometry(p) {
|
||||
const d = p.dims;
|
||||
if (p.shape === 'PANEL') {
|
||||
return new THREE.BoxGeometry(d.w, d.t, d.length);
|
||||
}
|
||||
if (p.shape === 'CHANNEL') {
|
||||
const B = d.w, H = d.h, t = d.tw, tf = d.tf;
|
||||
const s = new THREE.Shape();
|
||||
s.moveTo(-B / 2, -H / 2); s.lineTo(B / 2, -H / 2); s.lineTo(B / 2, -H / 2 + tf);
|
||||
s.lineTo(-B / 2 + t, -H / 2 + tf); s.lineTo(-B / 2 + t, H / 2 - tf); s.lineTo(B / 2, H / 2 - tf);
|
||||
s.lineTo(B / 2, H / 2); s.lineTo(-B / 2, H / 2); s.closePath();
|
||||
const g = new THREE.ExtrudeGeometry(s, { depth: d.length, bevelEnabled: false, steps: 1 });
|
||||
g.translate(0, 0, -d.length / 2);
|
||||
g.applyMatrix4(new THREE.Matrix4().set(0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1));
|
||||
return g;
|
||||
}
|
||||
const g = new THREE.ExtrudeGeometry(hSection(d), { depth: d.length, bevelEnabled: false, steps: 1 });
|
||||
g.translate(0, 0, -d.length / 2);
|
||||
return g;
|
||||
}
|
||||
|
||||
const groups = {};
|
||||
const excavationMeshes = [];
|
||||
const allBox = new THREE.Box3();
|
||||
const target = new THREE.Vector3();
|
||||
let radius = 1000, theta = Math.PI / 4, phi = Math.PI / 3;
|
||||
|
||||
function updateCamera() {
|
||||
const sp = Math.sin(phi);
|
||||
camera.position.set(
|
||||
target.x + radius * sp * Math.cos(theta),
|
||||
target.y + radius * sp * Math.sin(theta),
|
||||
target.z + radius * Math.cos(phi)
|
||||
);
|
||||
camera.lookAt(target);
|
||||
}
|
||||
|
||||
function fitVisible() {
|
||||
const box = new THREE.Box3();
|
||||
Object.values(groups).forEach(list => list.forEach(m => { if (m.visible) box.expandByObject(m); }));
|
||||
if (box.isEmpty()) return;
|
||||
box.getCenter(target);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
radius = Math.max(size.x, size.y, size.z) / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.1;
|
||||
updateCamera();
|
||||
}
|
||||
|
||||
function buildJack() {
|
||||
// 상세도 jack 블록(mm): 길이 599.5(Y축), 높이 309.3(Z축), 폭 0.3m(X축)을 m로 환산
|
||||
const g = new THREE.Group();
|
||||
const add = (geo, x, y, z) => {
|
||||
const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0xfbbf24, roughness: 0.6 }));
|
||||
m.position.set(x, y, z);
|
||||
g.add(m);
|
||||
};
|
||||
add(new THREE.BoxGeometry(0.30, 0.0141, 0.3093), 0, -0.29268, 0);
|
||||
add(new THREE.BoxGeometry(0.30, 0.0145, 0.3093), 0, 0.29253, 0);
|
||||
add(new THREE.BoxGeometry(0.20, 0.5709, 0.1557), 0, 0, 0);
|
||||
add(new THREE.CylinderGeometry(0.0467, 0.0467, 0.3218, 20), 0, 0, 0);
|
||||
return g;
|
||||
}
|
||||
|
||||
fetch('gasiseol-v4-stats.json')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
data.parts.forEach(p => {
|
||||
const mesh = p.shape === 'JACK' ? buildJack() : new THREE.Mesh(buildGeometry(p), new THREE.MeshStandardMaterial({ color: COLORS[p.memberType] || 0x999999, roughness: 0.7 }));
|
||||
const axisHoriz = p.axis === 'horizontal';
|
||||
// 수직 부재: 평면 단면(x-y), 길이는 z 방향 (지표 z=0 에서 아래로)
|
||||
// 수평 부재(버팀보): 길이를 x 방향으로 눕힘, 설치 깊이는 말뚝 길이의 절반
|
||||
if (p.axis === 'horizontal') mesh.rotation.y = Math.PI / 2;
|
||||
if (p.axis === 'horizontalY') mesh.rotation.x = Math.PI / 2;
|
||||
mesh.position.set(p.position.x, p.position.y, p.position.z);
|
||||
scene.add(mesh);
|
||||
allBox.expandByObject(mesh);
|
||||
(groups[p.memberType] = groups[p.memberType] || []).push(mesh);
|
||||
});
|
||||
|
||||
const buildSurface = (y0, y1, profile, color) => {
|
||||
const pos = [];
|
||||
for (let i = 0; i + 1 < profile.length; i++) {
|
||||
const a = profile[i], b = profile[i + 1];
|
||||
const A = [a.x, y0, a.z], B = [a.x, y1, a.z], C = [b.x, y0, b.z], D = [b.x, y1, b.z];
|
||||
pos.push(...A, ...B, ...C, ...C, ...B, ...D);
|
||||
}
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
||||
g.computeVertexNormals();
|
||||
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.3, side: THREE.DoubleSide, depthWrite: false }));
|
||||
scene.add(m);
|
||||
excavationMeshes.push(m);
|
||||
allBox.expandByObject(m);
|
||||
};
|
||||
buildSurface(data.excavation.yA, data.excavation.yB, data.excavation.profile, 0x8ecae6);
|
||||
data.road.forEach(r => buildSurface(r.y0, r.y1, r.profile, 0xd6b98c));
|
||||
document.getElementById('chkExc').addEventListener('change', e => {
|
||||
excavationMeshes.forEach(m => m.visible = e.target.checked);
|
||||
});
|
||||
|
||||
const total = data.totalParts;
|
||||
countsEl.innerHTML = Object.keys(groups).map(name => {
|
||||
const sample = data.parts.find(p => p.memberType === name);
|
||||
return `<label class="row" style="cursor:pointer"><span><input type="checkbox" data-type="${name}" checked> ${name}</span><span>${groups[name].length}</span></label><div class="spec">${sample.spec}</div><div class="row" style="align-items:center"><span class="spec">투명도</span><input type="range" min="0.1" max="1" step="0.05" value="1" data-op="${name}" style="flex:1;margin-left:8px"></div>`;
|
||||
}).join('') + `<div class="row" style="border-top:1px solid #9fb3c8;margin-top:4px;padding-top:4px"><b>합계</b><b id="visibleTotal"></b></div>`;
|
||||
|
||||
const updateTotal = () => {
|
||||
let shown = 0;
|
||||
Object.values(groups).forEach(list => { if (list[0].visible) shown += list.length; });
|
||||
document.getElementById('visibleTotal').textContent = shown + ' / ' + total;
|
||||
};
|
||||
countsEl.querySelectorAll('input[type=checkbox]').forEach(box => {
|
||||
box.addEventListener('change', () => {
|
||||
groups[box.dataset.type].forEach(m => m.visible = box.checked);
|
||||
updateTotal();
|
||||
});
|
||||
});
|
||||
countsEl.querySelectorAll('input[type=range][data-op]').forEach(slider => {
|
||||
slider.addEventListener('input', () => {
|
||||
const o = parseFloat(slider.value);
|
||||
groups[slider.dataset.op].forEach(obj => obj.traverse(c => {
|
||||
if (!c.isMesh) return;
|
||||
c.material.transparent = o < 1;
|
||||
c.material.opacity = o;
|
||||
c.material.depthWrite = o >= 1;
|
||||
c.material.needsUpdate = true;
|
||||
}));
|
||||
});
|
||||
});
|
||||
updateTotal();
|
||||
fitVisible();
|
||||
})
|
||||
.catch(err => { countsEl.textContent = '오류: ' + err.message; });
|
||||
|
||||
document.getElementById('btnFit').addEventListener('click', fitVisible);
|
||||
|
||||
const VIEWS = {
|
||||
front: { theta: Math.PI / 2, phi: Math.PI / 2 },
|
||||
top: { theta: -Math.PI / 2, phi: 0.02 },
|
||||
side: { theta: 0, phi: Math.PI / 2 },
|
||||
iso: { theta: Math.PI / 4, phi: Math.PI / 3 }
|
||||
};
|
||||
document.querySelectorAll('button.vb[data-view]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const v = VIEWS[btn.dataset.view];
|
||||
theta = v.theta; phi = v.phi;
|
||||
fitVisible();
|
||||
document.querySelectorAll('button.vb[data-view]').forEach(b => b.classList.toggle('on', b === btn));
|
||||
});
|
||||
});
|
||||
|
||||
const measureLabel = document.createElement('div');
|
||||
measureLabel.id = 'measureLabel';
|
||||
document.body.appendChild(measureLabel);
|
||||
const measurePts = [];
|
||||
const measureMarkers = [];
|
||||
const measureLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffd166, depthTest: false }));
|
||||
measureLine.renderOrder = 999;
|
||||
scene.add(measureLine);
|
||||
let measuring = false;
|
||||
|
||||
function clearMeasure() {
|
||||
measureMarkers.forEach(m => { scene.remove(m); m.geometry.dispose(); m.material.dispose(); });
|
||||
measureMarkers.length = 0;
|
||||
measurePts.length = 0;
|
||||
measureLine.geometry.dispose();
|
||||
measureLine.geometry = new THREE.BufferGeometry();
|
||||
measureLabel.style.display = 'none';
|
||||
}
|
||||
|
||||
function pickPoint(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
||||
camera.updateMatrixWorld();
|
||||
raycaster.setFromCamera(ndc, camera);
|
||||
const targets = [];
|
||||
Object.values(groups).forEach(list => list.forEach(m => { if (m.visible) targets.push(m); }));
|
||||
const hit = raycaster.intersectObjects(targets, true)[0];
|
||||
if (!hit) return;
|
||||
if (measurePts.length === 2) clearMeasure();
|
||||
measurePts.push(hit.point.clone());
|
||||
const marker = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(Math.max(0.05, radius * 0.005), 12, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffd166, depthTest: false })
|
||||
);
|
||||
marker.position.copy(hit.point);
|
||||
marker.renderOrder = 999;
|
||||
scene.add(marker);
|
||||
measureMarkers.push(marker);
|
||||
if (measurePts.length === 1) {
|
||||
measureLabel.textContent = '첫 점 선택됨 · 두 번째 점을 클릭';
|
||||
measureLabel.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
const [a, b] = measurePts;
|
||||
measureLine.geometry = new THREE.BufferGeometry().setFromPoints([a, b]);
|
||||
measureLabel.textContent = `거리 ${a.distanceTo(b).toFixed(3)} m (ΔX ${(b.x - a.x).toFixed(3)} · ΔY ${(b.y - a.y).toFixed(3)} · ΔZ ${(b.z - a.z).toFixed(3)})`;
|
||||
measureLabel.style.display = 'block';
|
||||
}
|
||||
|
||||
const btnMeasure = document.getElementById('btnMeasure');
|
||||
btnMeasure.addEventListener('click', () => {
|
||||
measuring = !measuring;
|
||||
btnMeasure.textContent = '거리 측정: ' + (measuring ? '켜짐' : '끔');
|
||||
btnMeasure.classList.toggle('on', measuring);
|
||||
canvas.style.cursor = measuring ? 'crosshair' : 'default';
|
||||
});
|
||||
document.getElementById('btnClear').addEventListener('click', clearMeasure);
|
||||
|
||||
let mode = null, lastX = 0, lastY = 0, downX = 0, downY = 0;
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
mode = e.button === 2 ? 'pan' : 'rotate';
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
downX = e.clientX; downY = e.clientY;
|
||||
});
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (measuring && mode === 'rotate' && Math.hypot(e.clientX - downX, e.clientY - downY) < 4) pickPoint(e);
|
||||
mode = null;
|
||||
});
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!mode) return;
|
||||
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
if (mode === 'rotate') {
|
||||
theta -= dx * 0.005;
|
||||
phi = Math.min(Math.PI - 0.01, Math.max(0.01, phi - dy * 0.005));
|
||||
} else {
|
||||
const right = new THREE.Vector3(), up = new THREE.Vector3();
|
||||
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
||||
const s = radius * 0.0015;
|
||||
target.addScaledVector(right, -dx * s);
|
||||
target.addScaledVector(up, dy * s);
|
||||
}
|
||||
updateCamera();
|
||||
});
|
||||
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const ground = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
|
||||
canvas.addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
|
||||
zoomTarget = Math.min(1e6, Math.max(0.05, (zoomTarget ?? radius) * Math.exp(dy * 0.0015)));
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
||||
camera.updateMatrixWorld();
|
||||
raycaster.setFromCamera(ndc, camera);
|
||||
const hit = new THREE.Vector3();
|
||||
zoomPivot = raycaster.ray.intersectPlane(ground, hit) ? hit : target.clone();
|
||||
}, { passive: false });
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
});
|
||||
|
||||
let zoomTarget = null, zoomPivot = null;
|
||||
(function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
if (zoomTarget !== null) {
|
||||
const prev = radius;
|
||||
let next = prev + (zoomTarget - prev) * 0.18;
|
||||
if (Math.abs(next - zoomTarget) < zoomTarget * 0.0005) { next = zoomTarget; zoomTarget = null; }
|
||||
target.lerp(zoomPivot, 1 - next / prev);
|
||||
radius = next;
|
||||
updateCamera();
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* generate-v4.mjs - 평면도 기준 통합 모델 (엄지말뚝 2열 기준 재배치)
|
||||
*
|
||||
* 기준 (사용자 제공):
|
||||
* - 엄지말뚝 상단 열 A 시작점 (-1248.4329, -2617.9386), 하단 열 B 시작점 (-1248.4329, -2631.1386)
|
||||
* - 각 열은 X 방향 1m 간격 61개 (엄지말뚝 h-pile2), 열 사이 Y 13.2m
|
||||
* - 토류판은 같은 열의 엄지말뚝 사이에만 60개씩 배치
|
||||
*
|
||||
* 나머지 부재 (가시설4, H-PILE, 스터럿): 계획도 데이터에서 말뚝 열 4개 중 상단/하단 열과의
|
||||
* 상대 위치(Δx, Δy)를 유지해 A/B 열 기준으로 재배치.
|
||||
*/
|
||||
|
||||
import DxfParser from 'dxf-parser';
|
||||
import fs from 'fs';
|
||||
|
||||
const MEMBERS = {
|
||||
'h-pile2': { memberType: '엄지말뚝', spec: 'H400×400×13/21', shape: 'H', h: 0.40, w: 0.40, tf: 0.021, tw: 0.013, length: 8.5, axis: 'vertical' },
|
||||
'띠장': { memberType: '띠장', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 60, axis: 'horizontal' },
|
||||
'토류판': { memberType: '토류판', spec: 'T=100 (0.9×0.1)', shape: 'PANEL', w: 0.90, t: 0.10, length: 8.5, axis: 'vertical' },
|
||||
'H-PILE': { memberType: '중간말뚝', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 8.5, axis: 'vertical' },
|
||||
'가시설4': { memberType: '수직 H300 (미확정)', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 8.5, axis: 'vertical' },
|
||||
'스터럿': { memberType: '버팀보', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 12.8, axis: 'horizontal' },
|
||||
'수직PIECE': { memberType: '수직 PIECE', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 0.6, axis: 'vertical' },
|
||||
'스크류잭': { memberType: '스크류잭', spec: '1000kN', shape: 'JACK', h: 0.309, w: 0.30, length: 0.5995, axis: 'jackY' },
|
||||
'채널': { memberType: '채널', spec: 'ㄷ-380×100×10.5/16', shape: 'CHANNEL', h: 0.38, w: 0.10, tf: 0.016, tw: 0.0105, length: 1, axis: 'channelX' },
|
||||
'버팀보Y': { memberType: '버팀보', spec: 'H300×300×10/15', shape: 'H', h: 0.30, w: 0.30, tf: 0.015, tw: 0.010, length: 12.2, axis: 'horizontalY' }
|
||||
};
|
||||
|
||||
const ROW_A = { x: -1248.4329, y: -2617.9386 };
|
||||
const ROW_B = { x: -1248.4329, y: -2631.1386 };
|
||||
const PILES_PER_ROW = 61;
|
||||
const SPACING = 1.0;
|
||||
|
||||
// A-A 단면: 엄지말뚝 상단 EL 77.97, 하단 EL 69.47 (L=8.5). 띠장 2단 EL 77.1, 74.9
|
||||
const PILE_LENGTH = 8.5;
|
||||
const PILE_CENTER_Z = 77.97 - PILE_LENGTH / 2;
|
||||
const WALE_LEVELS = [77.1, 74.9];
|
||||
const WALE_LENGTH = (PILES_PER_ROW - 1) * SPACING;
|
||||
const PILE_HALF = 0.2;
|
||||
const WALE_HALF = 0.15;
|
||||
const PANEL_HALF = 0.05;
|
||||
const FLANGE_T = 0.021;
|
||||
|
||||
const plan = new DxfParser().parse(fs.readFileSync('RAG/LH/가시설계획_LH.dxf', 'utf-8'));
|
||||
const inserts = plan.entities.filter(e => e.type === 'INSERT' && MEMBERS[e.name]);
|
||||
|
||||
const parts = [];
|
||||
const pushPart = (blockName, pos) => {
|
||||
const m = MEMBERS[blockName];
|
||||
parts.push({
|
||||
blockType: blockName,
|
||||
memberType: m.memberType,
|
||||
spec: m.spec,
|
||||
shape: m.shape,
|
||||
dims: { h: m.h, w: m.w, tf: m.tf, tw: m.tw, t: m.t, length: pos.length ?? m.length },
|
||||
axis: m.axis,
|
||||
position: { x: +pos.x.toFixed(4), y: +pos.y.toFixed(4), z: +(pos.z ?? PILE_CENTER_Z).toFixed(4) }
|
||||
});
|
||||
};
|
||||
|
||||
// A-A 단면: 벽체 방향 X 구간별 말뚝 상단 표고 (0.3m 단위 6그룹, 말뚝 길이 8.5m)
|
||||
const SECTION_X0 = -1136.05, SECTION_X1 = -1080.05;
|
||||
const GROUP_EDGES = [-1128.40, -1119.40, -1110.40, -1096.40, -1086.40];
|
||||
const GROUP_TOPS = [77.97, 77.67, 77.37, 77.07, 76.77, 76.47];
|
||||
const groupOf = secX => {
|
||||
let g = 0;
|
||||
while (g < GROUP_EDGES.length && secX > GROUP_EDGES[g]) g++;
|
||||
return g;
|
||||
};
|
||||
const pileGroup = j => {
|
||||
const secX = SECTION_X0 + ((WALE_LENGTH - j * SPACING) / WALE_LENGTH) * (SECTION_X1 - SECTION_X0);
|
||||
return groupOf(secX);
|
||||
};
|
||||
const topOfPile = j => GROUP_TOPS[pileGroup(j)];
|
||||
|
||||
[ROW_A, ROW_B].forEach(row => {
|
||||
for (let i = 0; i < PILES_PER_ROW; i++) {
|
||||
const top = topOfPile(i);
|
||||
pushPart('h-pile2', { x: row.x + i * SPACING, y: row.y, z: top - PILE_LENGTH / 2 });
|
||||
}
|
||||
const panelInner = (row === ROW_A ? -1 : 1) * (PILE_HALF - FLANGE_T - PANEL_HALF);
|
||||
for (let j = 0; j < PILES_PER_ROW - 1; j++) {
|
||||
const top = Math.min(topOfPile(j), topOfPile(j + 1));
|
||||
pushPart('토류판', { x: row.x + j * SPACING + SPACING / 2, y: row.y + panelInner, z: top - PILE_LENGTH / 2 });
|
||||
}
|
||||
|
||||
const innerSign = row === ROW_A ? -1 : 1;
|
||||
const waleY = row.y + innerSign * (PILE_HALF + WALE_HALF);
|
||||
for (let g = 0; g < GROUP_TOPS.length; g++) {
|
||||
const piles = [];
|
||||
for (let j = 0; j < PILES_PER_ROW; j++) if (pileGroup(j) === g) piles.push(j);
|
||||
if (piles.length === 0) continue;
|
||||
const x0 = row.x + piles[0] * SPACING;
|
||||
const x1 = row.x + piles[piles.length - 1] * SPACING;
|
||||
const shift = GROUP_TOPS[g] - GROUP_TOPS[0];
|
||||
WALE_LEVELS.forEach(level => {
|
||||
const c = level + shift;
|
||||
[c - WALE_HALF, c + WALE_HALF].forEach(z => {
|
||||
pushPart('띠장', { x: (x0 + x1) / 2, y: waleY, z, length: Math.max(x1 - x0, SPACING) + 2 * PILE_HALF });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const blockCenter = name => {
|
||||
const xs = [], ys = [];
|
||||
plan.blocks[name].entities.forEach(e => {
|
||||
if (e.vertices) e.vertices.forEach(v => { xs.push(v.x); ys.push(v.y); });
|
||||
if (e.center && e.radius) { xs.push(e.center.x - e.radius, e.center.x + e.radius); ys.push(e.center.y - e.radius, e.center.y + e.radius); }
|
||||
});
|
||||
return { x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2 };
|
||||
};
|
||||
const trueCenter = i => {
|
||||
const c = blockCenter(i.name), r = ((i.rotation || 0) * Math.PI) / 180;
|
||||
return {
|
||||
x: i.position.x + c.x * Math.cos(r) - c.y * Math.sin(r),
|
||||
y: i.position.y + c.x * Math.sin(r) + c.y * Math.cos(r)
|
||||
};
|
||||
};
|
||||
const inWall = p => p.x >= ROW_A.x - 0.5 && p.x <= ROW_A.x + WALE_LENGTH + 0.5 && p.y >= ROW_B.y - 1 && p.y <= ROW_A.y + 1;
|
||||
const planCenters = name => plan.entities.filter(e => e.type === 'INSERT' && e.name === name).map(trueCenter).filter(inWall);
|
||||
const BUTT_STATIONS = [0.5, ...Array.from({ length: 12 }, (_, k) => 4.5 + 5 * k)];
|
||||
const WALE_OFFSET = PILE_HALF + WALE_HALF;
|
||||
const waleYA = ROW_A.y - WALE_OFFSET;
|
||||
const waleYB = ROW_B.y + WALE_OFFSET;
|
||||
const PIECE_HALF = 0.15;
|
||||
const pieceYA = waleYA - WALE_HALF - PIECE_HALF;
|
||||
const pieceYB = waleYB + WALE_HALF + PIECE_HALF;
|
||||
const BUTT_CENTER_Y = (pieceYA + pieceYB) / 2;
|
||||
const JACK_L = 0.5995;
|
||||
const BUTT_LENGTH = (pieceYA - pieceYB) - 2 * PIECE_HALF - JACK_L;
|
||||
const buttEndA = pieceYA - PIECE_HALF;
|
||||
const buttEndB = pieceYB + PIECE_HALF;
|
||||
const jackOnA = i => (BUTT_STATIONS.length - 1 - i) % 2 === 0;
|
||||
BUTT_STATIONS.forEach((s, i) => {
|
||||
const g = pileGroup(Math.round(s / SPACING));
|
||||
const shift = GROUP_TOPS[g] - GROUP_TOPS[0];
|
||||
const onA = jackOnA(i);
|
||||
const buttY = onA ? (buttEndB + buttEndA - JACK_L) / 2 : (buttEndB + JACK_L + buttEndA) / 2;
|
||||
WALE_LEVELS.forEach(level => {
|
||||
const c = level + shift;
|
||||
pushPart('수직PIECE', { x: ROW_A.x + s, y: pieceYA, z: c });
|
||||
pushPart('수직PIECE', { x: ROW_B.x + s, y: pieceYB, z: c });
|
||||
pushPart('스크류잭', onA
|
||||
? { x: ROW_A.x + s, y: buttEndA - JACK_L / 2, z: c }
|
||||
: { x: ROW_A.x + s, y: buttEndB + JACK_L / 2, z: c });
|
||||
pushPart('버팀보Y', { x: ROW_A.x + s, y: buttY, z: c, length: BUTT_LENGTH });
|
||||
});
|
||||
});
|
||||
|
||||
const hpCenters = planCenters('H-PILE');
|
||||
hpCenters.forEach(p => {
|
||||
const j = Math.round((p.x - ROW_A.x) / SPACING);
|
||||
pushPart('H-PILE', { x: p.x, y: p.y, z: topOfPile(j) - PILE_LENGTH / 2 });
|
||||
});
|
||||
|
||||
const CHANNEL_H = 0.38, CHANNEL_B = 0.10, H_PILE_HALF = 0.15;
|
||||
const runs = [];
|
||||
[...hpCenters].sort((a, b) => a.x - b.x).forEach(p => {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && Math.abs(last.y - p.y) < 0.01) last.xs.push(p.x);
|
||||
else runs.push({ y: p.y, xs: [p.x] });
|
||||
});
|
||||
runs.forEach(r => {
|
||||
const x0 = Math.min(...r.xs), x1 = Math.max(...r.xs);
|
||||
const xc = (x0 + x1) / 2;
|
||||
const g = pileGroup(Math.round((xc - ROW_A.x) / SPACING));
|
||||
const shift = GROUP_TOPS[g] - GROUP_TOPS[0];
|
||||
const cy = r.y > BUTT_CENTER_Y ? r.y - H_PILE_HALF - CHANNEL_B / 2 : r.y + H_PILE_HALF + CHANNEL_B / 2;
|
||||
WALE_LEVELS.forEach(level => {
|
||||
const c = level + shift;
|
||||
pushPart('채널', { x: xc, y: cy, z: c + WALE_HALF + CHANNEL_H / 2, length: x1 - x0 + 2 * H_PILE_HALF });
|
||||
});
|
||||
});
|
||||
|
||||
const counts = {};
|
||||
parts.forEach(p => { counts[p.memberType] = (counts[p.memberType] || 0) + 1; });
|
||||
|
||||
// 굴착 바닥: A-A 녹색 경사선 (단면 X -1146.49, EL 72.91 → X -1070.91, EL 70.58) 을 벽체 X 범위에 대응
|
||||
const GREEN_P0 = { secX: -1146.49, el: 72.91 }, GREEN_P1 = { secX: -1070.91, el: 70.58 };
|
||||
const greenEl = secX => GREEN_P0.el + ((secX - GREEN_P0.secX) * (GREEN_P1.el - GREEN_P0.el)) / (GREEN_P1.secX - GREEN_P0.secX);
|
||||
const secOfPlanX = x => SECTION_X1 - ((x - ROW_A.x) * (SECTION_X1 - SECTION_X0)) / WALE_LENGTH;
|
||||
const EXC_STEP = 1;
|
||||
const SURFACE_EXTEND = 5;
|
||||
const excStations = Array.from({ length: (WALE_LENGTH + 2 * SURFACE_EXTEND) / EXC_STEP + 1 }, (_, k) => ROW_A.x - SURFACE_EXTEND + k * EXC_STEP);
|
||||
const excavation = {
|
||||
yA: ROW_A.y - PILE_HALF,
|
||||
yB: ROW_B.y + PILE_HALF,
|
||||
profile: excStations.map(x => ({ x, z: greenEl(secOfPlanX(x)) }))
|
||||
};
|
||||
|
||||
// 도로면: A-A 가장 높은 녹색 점선 (단면 X -1146.49, EL 77.01 → X -1070.91, EL 74.68), 엄지말뚝 외측 양쪽
|
||||
const ROAD_P0 = { secX: -1146.49, el: 77.01 }, ROAD_P1 = { secX: -1070.91, el: 74.68 };
|
||||
const roadEl = secX => ROAD_P0.el + ((secX - ROAD_P0.secX) * (ROAD_P1.el - ROAD_P0.el)) / (ROAD_P1.secX - ROAD_P0.secX);
|
||||
const ROAD_WIDTH = 10;
|
||||
const roadProfile = excStations.map(x => ({ x, z: roadEl(secOfPlanX(x)) }));
|
||||
const road = [
|
||||
{ y0: ROW_A.y + PILE_HALF, y1: ROW_A.y + PILE_HALF + ROAD_WIDTH, profile: roadProfile },
|
||||
{ y0: ROW_B.y - PILE_HALF - ROAD_WIDTH, y1: ROW_B.y - PILE_HALF, profile: roadProfile }
|
||||
];
|
||||
|
||||
const xs = parts.map(p => p.position.x), ys = parts.map(p => p.position.y);
|
||||
const stats = {
|
||||
timestamp: new Date().toISOString(),
|
||||
method: 'v4-plan-2row',
|
||||
unit: 'm',
|
||||
origin: '평면도 좌표 (엄지말뚝 열 A/B 시작점 기준)',
|
||||
totalParts: parts.length,
|
||||
partsByType: counts,
|
||||
parts,
|
||||
excavation,
|
||||
road
|
||||
};
|
||||
fs.writeFileSync('out/gasiseol-v4-stats.json', JSON.stringify(stats, null, 2), 'utf-8');
|
||||
|
||||
console.log('부재 구성:', counts);
|
||||
console.log(`범위 X ${Math.min(...xs).toFixed(1)}~${Math.max(...xs).toFixed(1)} Y ${Math.min(...ys).toFixed(1)}~${Math.max(...ys).toFixed(1)}`);
|
||||
Reference in new issue
Block a user