티스토리 뷰

반응형

three.js 파라메트릭 모델링 슬라이더로 바뀌는 교량 교량 BIM 바이브코딩 6편 대표 이미지

 

5편에서는 슬라이더로 I형 거더의 단면 치수를 바꾸면 3D 모델과 단면2차모멘트가 함께 바뀌는 데모를 만들었다. 6편에서는 같은 방식을 교량 전체로 넓힌다. 경간 수, 경간 길이, 바닥판 폭, 거더 개수, 교각 높이를 슬라이더로 바꾸면 교각 위치, 거더 간격, 물량까지 규칙에 따라 다시 계산되는 파라메트릭 교량 모델이다.

Revit 패밀리나 Tekla 사용자 컴포넌트를 써봤다면 익숙한 개념이다. 차이는 그 규칙을 내가 직접 코드로 정한다는 것, 그리고 Claude에게 맡기면 그 코드를 몇 분 만에 받을 수 있다는 것이다.

 

이번 편에서 만들 것
 

▲ 실제로 동작하는 데모. 위쪽 버튼으로 예시 교량을 고르거나, 슬라이더로 치수를 바꿔보자. 모델·물량·그래프가 함께 바뀐다.

 

○ 경간 수를 바꾸면 교각 개수와 위치가, 바닥판 폭이나 거더 개수를 바꾸면 거더 간격이 자동으로 다시 계산된다. 숫자 하나를 바꿨을 뿐인데 관련된 모든 부재가 따라 움직인다.

○ 그래프는 경간 길이에 따른 바닥판 면적당 강재량이다. 거더 높이를 경간의 1/20로 잡으면 경간이 길어질수록 강재량이 빠르게 늘어난다. 물량은 단면을 단순화한 개략값이며 실제 설계 물량과는 다르다.

 

파라메트릭 모델이란

○ 파라미터(치수)와 규칙을 분리한 모델이다. 숫자는 한곳에 모아두고, 부재의 위치와 크기는 그 숫자로부터 계산한다. 숫자를 바꾸면 규칙에 따라 모델 전체가 다시 만들어진다.

 

파라메트릭 모델 파라미터 규칙 build 함수 3D 모델 흐름

 

구분 숫자를 직접 넣은 코드 (1~5편) 파라메트릭 코드 (6편)
경간 길이 변경 바닥판·거더·교각 위치를 하나씩 수정 L 하나만 수정
경간 수 변경 교각을 복사해서 좌표를 다시 계산 nSpan만 수정, 교각은 반복문이 생성
거더 개수 변경 간격을 손으로 계산해서 z 좌표 입력 n만 수정, 간격은 공식으로 계산
실수 가능성 한 군데 빠뜨리면 부재가 어긋남 규칙이 맞으면 항상 맞물림
재사용 다른 교량에 쓰려면 거의 새로 작성 파라미터만 바꿔서 다른 교량 생성

 

 

BIM 도구와 비교하면
도구 파라미터 규칙 결과
Revit 패밀리 유형·인스턴스 매개변수 구속조건, 수식 패밀리 형상
Dynamo · Grasshopper 입력 노드 (슬라이더) 노드 연결 형상 생성
Tekla 사용자 컴포넌트 컴포넌트 속성 부재 간 규칙 부재·접합부
three.js (이 글) P 객체 build(P) 함수 장면 속 메시

 

○ 개념은 같다. 상용 도구는 규칙을 그래픽으로 만들고, 여기서는 자바스크립트 함수로 만든다는 차이뿐이다. 함수이기 때문에 반복문, 조건문, 계산식을 자유롭게 쓸 수 있다.

 

1단계: 파라미터 정하기

○ 먼저 무엇을 바꿀 수 있게 할지 정한다. 교량 형식을 결정하는 핵심 치수만 고르고, 나머지는 거기서 계산되도록 한다. 파라미터가 너무 많으면 서로 맞지 않는 조합이 생기기 쉽다.

파라미터 의미 범위 (데모) 단위
nSpan 경간 수 1 ~ 4 개
L 경간 길이 (지점 간 거리) 20 ~ 60 m
B 바닥판 전체 폭 8 ~ 20 m
n 거더 개수 2 ~ 8 개
H 교각 높이 (지면 ~ 거더 하단) 4 ~ 20 m
// 1. 파라미터 - 숫자는 여기에만 둔다 (단위 m)
const P = {
  nSpan: 3,   // 경간 수
  L: 40,      // 경간 길이
  B: 12,      // 바닥판 폭
  n: 4,       // 거더 개수
  H: 8,       // 교각 높이
};

 

2단계: 파생값 계산하기

○ 파생값은 파라미터로부터 계산되는 값이다. 거더 간격은 바닥판 폭과 거더 개수가 정해지면 자동으로 결정된다. 이 값을 따로 입력받으면 폭과 간격이 서로 맞지 않는 모델이 나올 수 있다.

 

교량 횡단면 거더 간격 계산 바닥판 폭 캔틸레버 거더 개수

 

파생값 계산식 기본값 결과
거더 높이 h L / 20 (모델링 초기값) 2.00 m
캔틸레버 c 바깥 거더 중심 ~ 바닥판 끝, 1.0 m 고정 1.00 m
거더 중심 간격 s (B − 2c) / (n − 1) 3.33 m
총 연장 nSpan × L 120 m
교각 위치 −총연장/2 + L × k (k = 1 ~ nSpan−1) x = −20, 20 m
교각 기둥 수 B > 13 m이면 2개, 아니면 1개 1개

 

○ 거더 높이 L/20은 모델 형상을 보기 위한 초기값일 뿐 설계값이 아니다. 실제 거더 높이는 하중, 처짐, 경제성을 검토해서 정한다. 데모에서는 "거더 높이 자동"을 끄면 직접 조정할 수 있다.

// 2. 파생값 - 파라미터로 계산
function derive(P) {
  const h = P.L / 20;                    // 거더 높이 (초기값)
  const c = 1.0;                         // 바깥 거더 중심 ~ 바닥판 끝
  const s = (P.B - 2 * c) / (P.n - 1);   // 거더 중심 간격
  const Ltot = P.nSpan * P.L;            // 총 연장
  return { h, c, s, Ltot };
}

 

3단계: build 함수 - 파라미터로 부재 만들기

○ 모델을 만드는 코드를 build(P) 함수 하나에 모은다. 함수 안에는 숫자 대신 P와 파생값만 쓴다. 반복문으로 거더와 교각을 필요한 개수만큼 만든다.

// 6. 모델 생성: 파라미터 → 부재
function build(P) {
  clearBridge();                         // 이전 모델 지우기
  const d = derive(P);
  const top = P.H + d.h;

  // 바닥판
  addBox(d.Ltot, 0.25, P.B, conc, 0, top + 0.125, 0);

  // 거더: 교폭 중앙 기준 대칭 배치
  for (let i = 0; i < P.n; i++) {
    const z = -(P.B / 2 - d.c) + i * d.s;
    addBox(d.Ltot, d.h, 0.4, steel, 0, P.H + d.h / 2, z);
  }

  // 교각: 경간 사이마다 하나씩
  for (let k = 1; k < P.nSpan; k++) {
    const x = -d.Ltot / 2 + k * P.L;
    addBox(1.6, P.H - 1.2, 1.6, conc, x, (P.H - 1.2) / 2, 0);   // 기둥
    addBox(2.2, 1.2, P.B - 1, conc, x, P.H - 0.6, 0);           // 코핑
  }
}
부재 개수 규칙 위치 규칙
바닥판 1개 거더 상단 위, 길이 = 총 연장
거더 n개 z = −(B/2 − c) + i × s
교각 nSpan − 1개 x = −총연장/2 + k × L
교대 2개 양 끝 (x = ±총연장/2)
가로보 (데모) 경간마다 약 6 m 간격 × (n − 1) 이웃한 거더 사이

 

4단계: 다시 그리기 - 지우고 새로 만들기

○ 슬라이더를 움직일 때마다 기존 부재를 모두 지우고 build(P)로 새로 만든다. 부재를 하나씩 찾아서 고치는 것보다 코드가 훨씬 단순하고, 개수가 바뀌는 경우(거더 4개 → 6개)도 자연스럽게 처리된다.

○ 이때 형상(geometry)의 메모리 해제를 잊으면 안 된다. 장면에서 빼기만 하면 GPU 메모리에는 형상 데이터가 그대로 남는다. 슬라이더를 수백 번 움직이면 메모리가 계속 늘어난다.

const bridge = new THREE.Group();   // 교량 부재를 담을 그룹
scene.add(bridge);

function clearBridge() {
  bridge.traverse((o) => {
    if (o.geometry) o.geometry.dispose();   // GPU 메모리 해제
  });
  bridge.clear();                           // 그룹에서 모두 제거
}
실수 증상 해결
dispose() 없이 지우기 오래 조작하면 점점 느려지고 메모리 증가 지우기 전에 geometry.dispose()
재질을 매번 새로 생성 메모리 증가, 생성 시간 증가 재질은 한 번 만들고 재사용
부재를 장면에 직접 추가 지울 대상을 찾기 어려움 그룹 하나에 담고 그룹만 비우기
카메라도 매번 초기화 돌려놓은 시점이 계속 튐 경간이 바뀔 때만 시점 맞추기

 

5단계: 슬라이더 연결하기

○ 슬라이더의 input 이벤트에서 할 일은 두 가지뿐이다. ① 파라미터 값을 바꾸고 ② build를 다시 호출한다.

function addSlider(key, label, min, max, step, unit) {
  const row = document.createElement('label');
  row.innerHTML = `<span>${label}</span>
    <input type="range" min="${min}" max="${max}" step="${step}" value="${P[key]}">
    <b>${P[key]} ${unit}</b>`;
  const input = row.querySelector('input');
  input.addEventListener('input', () => {
    P[key] = Number(input.value);   // ① 파라미터 변경
    build(P);                       // ② 모델 다시 생성
  });
  panel.appendChild(row);
}
addSlider('nSpan', '경간 수', 1, 4, 1, '개');
addSlider('L', '경간 길이', 20, 60, 1, 'm');

○ input.value는 문자열이다. Number()로 바꾸지 않으면 P.L * 2는 동작해도 P.L + 2는 "402"가 된다.

 

같은 build 함수로 만든 네 가지 파라메트릭 교량 비교

 

○ 위 네 교량은 모두 같은 build 함수로 만든 것이다. 바뀐 것은 파라미터 값뿐이다.

 

물량 자동 계산

○ 파라미터와 파생값이 있으면 물량도 바로 계산할 수 있다. 데모의 결과 표가 이렇게 계산한 값이다.

항목 계산 비고
거더 단면 상부플랜지 + 복부 + 하부플랜지 (5편 방식) 플랜지 폭 ≈ 0.22h, 복부 두께 ≈ 복부 높이/150
강재 중량 단면적 × 7,850 kg/m³ × 거더 개수 × 총 연장 × 1.15 가로보·보강재를 15% 할증으로 가정
바닥판 콘크리트 B × 0.25 × 총 연장 두께 0.25 m 가정, 헌치 제외
교각 콘크리트 (기둥 + 코핑) × 교각 수 기초 제외
교대 콘크리트 (본체 + 흉벽) × 2 날개벽·기초 제외

 

○ BIM의 장점이 바로 이것이다. 형상과 물량이 같은 파라미터에서 나오기 때문에, 치수를 바꾸면 물량도 즉시 따라온다. 대안 비교(경간 3 × 40 m vs 4 × 30 m)를 빠르게 해볼 수 있다.

대안 (폭 12 m, 거더 4개) 총 연장 거더 높이 강재 중량 (개략) 교각 수
3경간 × 40 m 120 m 2.00 m 약 227 t (158 kg/m²) 2개
4경간 × 30 m 120 m 1.50 m 약 154 t (107 kg/m²) 3개

 

○ 경간을 줄이면 강재량은 줄지만 교각이 하나 늘어난다. 실제 대안 비교에서는 하부구조 비용과 시공 조건까지 함께 본다.

 

전체 코드

○ 메모장에 붙여넣고 bridge6.html로 저장한 뒤 더블클릭하면 실행된다. 화면 왼쪽 위 슬라이더로 교량을 바꿀 수 있다. (데모의 I형 거더·가로보·물량 표는 빠진 간단한 버전)

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>three.js 파라메트릭 교량</title>
  <style>
    body { margin: 0; overflow: hidden; font-family: sans-serif; }
    #panel { position: absolute; top: 10px; left: 10px; background: rgba(255,255,255,0.9);
             padding: 10px 14px; border-radius: 8px; font-size: 14px; }
    #panel label { display: flex; align-items: center; gap: 8px; margin: 4px 0; }
    #panel span { width: 70px; }
    #panel b { width: 50px; text-align: right; }
  </style>
  <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
      }
    }
  </script>
</head>
<body>
<div id="panel"></div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 1. 파라미터 - 숫자는 여기에만 둔다 (단위 m)
const P = {
  nSpan: 3,   // 경간 수
  L: 40,      // 경간 길이
  B: 12,      // 바닥판 폭
  n: 4,       // 거더 개수
  H: 8,       // 교각 높이
};

// 2. 파생값 - 파라미터로 계산
function derive(P) {
  const h = P.L / 20;                    // 거더 높이 (초기값)
  const c = 1.0;                         // 바깥 거더 중심 ~ 바닥판 끝
  const s = (P.B - 2 * c) / (P.n - 1);   // 거더 중심 간격
  const Ltot = P.nSpan * P.L;            // 총 연장
  return { h, c, s, Ltot };
}

// 3. 장면, 카메라, 렌더러, 조명
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe8f0);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 2000);
camera.position.set(70, 50, 130);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x6b7f63, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 2.0);
sun.position.set(60, 100, 60);
scene.add(sun);

const ground = new THREE.Mesh(new THREE.PlaneGeometry(1000, 1000),
                              new THREE.MeshStandardMaterial({ color: 0x9fb39a }));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 6, 0);
controls.update();

// 4. 재질은 한 번만 만들고 재사용
const conc = new THREE.MeshStandardMaterial({ color: 0xb8bcc2 });
const steel = new THREE.MeshStandardMaterial({ color: 0x3f6e9e, metalness: 0.3, roughness: 0.5 });

// 5. 교량을 담을 그룹 + 지우기 함수
const bridge = new THREE.Group();
scene.add(bridge);

function addBox(w, h, d, mat, x, y, z) {
  const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
  mesh.position.set(x, y, z);
  bridge.add(mesh);
}

function clearBridge() {
  bridge.traverse((o) => {
    if (o.geometry) o.geometry.dispose();   // GPU 메모리 해제
  });
  bridge.clear();                           // 그룹에서 모두 제거
}

// 6. 모델 생성: 파라미터 → 부재
function build(P) {
  clearBridge();
  const d = derive(P);
  const top = P.H + d.h;

  // 바닥판
  addBox(d.Ltot, 0.25, P.B, conc, 0, top + 0.125, 0);

  // 거더: 교폭 중앙 기준 대칭 배치
  for (let i = 0; i < P.n; i++) {
    const z = -(P.B / 2 - d.c) + i * d.s;
    addBox(d.Ltot, d.h, 0.4, steel, 0, P.H + d.h / 2, z);
  }

  // 교각: 경간 사이마다 하나씩
  for (let k = 1; k < P.nSpan; k++) {
    const x = -d.Ltot / 2 + k * P.L;
    addBox(1.6, P.H - 1.2, 1.6, conc, x, (P.H - 1.2) / 2, 0);   // 기둥
    addBox(2.2, 1.2, P.B - 1, conc, x, P.H - 0.6, 0);           // 코핑
  }

  // 교대: 양 끝
  for (const sgn of [-1, 1]) {
    addBox(2.0, P.H, P.B + 1, conc, sgn * d.Ltot / 2, P.H / 2, 0);
  }
}

// 7. 슬라이더 → 파라미터 → 다시 생성
const panel = document.getElementById('panel');
function addSlider(key, label, min, max, step, unit) {
  const row = document.createElement('label');
  row.innerHTML = `<span>${label}</span>
    <input type="range" min="${min}" max="${max}" step="${step}" value="${P[key]}">
    <b>${P[key]} ${unit}</b>`;
  const input = row.querySelector('input');
  const out = row.querySelector('b');
  input.addEventListener('input', () => {
    P[key] = Number(input.value);   // ① 파라미터 변경
    out.textContent = `${P[key]} ${unit}`;
    build(P);                       // ② 모델 다시 생성
  });
  panel.appendChild(row);
}
addSlider('nSpan', '경간 수', 1, 4, 1, '개');
addSlider('L', '경간 길이', 20, 60, 1, 'm');
addSlider('B', '바닥판 폭', 8, 20, 0.5, 'm');
addSlider('n', '거더 개수', 2, 8, 1, '개');
addSlider('H', '교각 높이', 4, 20, 0.5, 'm');

build(P);
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>

 

Claude에게 이렇게 요청했다

three.js로 파라메트릭 교량 모델을 만들어줘.

- HTML 파일 하나, three.js 0.186.1 CDN importmap

- 파라미터는 객체 P 하나에 모으기: 경간 수 nSpan, 경간 길이 L, 바닥판 폭 B, 거더 개수 n, 교각 높이 H (단위 m)

- 파생값은 derive(P) 함수로: 거더 높이 L/20, 캔틸레버 1.0 m, 거더 간격 (B − 2) / (n − 1), 총 연장

- build(P) 함수 안에서는 숫자 대신 P와 파생값만 사용

- 부재는 Group 하나에 담고, 다시 그릴 때 geometry.dispose() 후 group.clear()

- 재질은 한 번만 만들어 재사용

- 화면 왼쪽 위에 슬라이더 5개, 값을 바꾸면 build(P) 다시 호출

- X축 교축, Y축 위, 교폭 중앙이 z = 0

 

○ 핵심은 "숫자는 P에만, build 안에서는 P만 사용"이라고 못 박는 것이다. 이 조건이 없으면 AI가 build 함수 안에 숫자를 그대로 적어 넣는 경우가 많고, 그러면 슬라이더를 움직여도 일부 부재만 바뀐다.

 

자주 겪는 문제
증상 원인 해결
슬라이더를 움직여도 모델이 안 바뀜 build 안에 숫자가 직접 들어 있음 숫자를 P나 파생값으로 바꾸기
부재가 겹쳐서 두 개씩 보임 지우지 않고 새로 추가 build 첫 줄에서 clearBridge()
오래 조작하면 느려짐 geometry를 dispose하지 않음 traverse로 dispose 후 clear
"402 m" 같은 이상한 값 input.value가 문자열 Number(input.value)
거더 1개일 때 오류 (n − 1)로 나누면 0으로 나누기 거더 개수 최소값을 2로
경간을 늘리면 교량이 화면 밖으로 카메라 위치가 고정 총 연장에 맞춰 카메라 거리 조정

 

정리

○ 파라메트릭 모델 = 파라미터(P) + 파생값(derive) + 규칙(build).

○ 숫자는 P에만 두고, build 안에서는 P와 파생값만 쓴다.

○ 다시 그릴 때는 dispose → clear → build 순서. 재질은 재사용한다.

○ 형상과 물량이 같은 파라미터에서 나오므로 대안 비교가 빨라진다.

 

다음 편 예고

지금까지 부재는 화면에 보이는 형상일 뿐이었다. 7편에서는 거더·바닥판·교각을 각각 이름과 속성을 가진 BIM 객체로 만든다. 부재마다 종류·재질·물량 정보를 붙이고, 마우스로 클릭하면 그 부재의 정보가 표시되는 교량 모델을 조립한다.

 

관련 글

     → [교량 BIM 바이브코딩 1편] three.js 입문: Claude 바이브코딩으로 3D 교량 만들기

     → [교량 BIM 바이브코딩 2편] three.js 기초: 장면·카메라·렌더러

     → [교량 BIM 바이브코딩 3편] OrbitControls·조명·그림자

     → [교량 BIM 바이브코딩 4편] AI 코딩 프롬프트 작성법: Claude에게 3D 모델 정확히 요청하기

     → [교량 BIM 바이브코딩 5편] three.js ExtrudeGeometry: I형 거더 단면 3D 모델링

     → [교량 BIM 바이브코딩 7편] three.js 교량 BIM 모델: 클릭하면 속성이 나오는 부재 조립

     → [교량 BIM 바이브코딩 8편] three.js 곡선교 모델링: 곡선 반경 R과 편경사로 휘는 교량

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/10   »
일 월 화 수 목 금 토
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31