브라우저에서 CAD 에디터를 구축하고 LLM에게 사용법을 가르친 과정
요약
AutoCAD DWG 파일을 파싱하여 브라우저 기반의 2D/3D CAD 에디터를 구축하고, Claude가 모델을 이해하고 편집할 수 있도록 도구 세트를 제공하는 과정을 다룹니다. 복잡한 기하학적 데이터를 구조화된 건물 모델로 변환하여 AI 에이전트가 실시간으로 편집 및 분석할 수 있는 시스템을 구현했습니다.
핵심 포인트
- DWG 파일을 DXF로 변환하여 기하학적 형상을 구조화된 데이터로 재구성
- LibreDWG를 서브프로세스로 실행하여 라이선스 문제와 안정성 해결
- AI 에이전트가 모델의 오류(예: 비정상적인 문 크기)를 스스로 감지하도록 설계
- React, Three.js, FastAPI를 활용한 풀스택 CAD 에디터 및 에이전트 통합
계획에 없던 데모 순간: 제가 앱에 "문과 창문이 각각 몇 개 있나요?"라고 물었더니, AI가 개수를 대답한 뒤 아무런 요청도 하지 않았는데 다음과 같이 덧붙였습니다.
참고: D3는 폭이 300mm뿐입니다. 문이 잘못 감지되었을 가능성이 높습니다. 확인해 드릴까요?
그것은 정확했습니다. 저의 추출 파이프라인(extraction pipeline)이 기하학적 형상(geometry)의 일부를 30cm짜리 문으로 변환해 버린 것이었습니다. 그 어떤 사람도 이를 알아차리지 못했습니다. 모델은 물량 산출(quantity take-off)을 읽고, 신발 상자보다 좁은 문을 발견한 뒤 이를 플래그(flag)로 표시했습니다.
그 작은 순간은 더 긴 이야기의 결실입니다. 현존하는 가장 까다로운 파일 형식 중 하나인 AutoCAD DWG를 파싱(parsing)하고, 수천 개의 익명 선분(line segments)으로부터 실제 건물 모델을 재구성하며, Canvas를 이용해 2D CAD 에디터를 처음부터 구축한 뒤, 이 모든 것을 Claude가 호출할 수 있는 도구(tools) 세트로 전달하는 과정 말입니다.
잘못된 부분들을 포함하여, 제가 이것을 어떻게 구축했는지 소개합니다.
작동 방식
업로드 내구성이 있는 ETL 작업 뷰어 + 에디터
브라우저 ────────▶ FastAPI ───────────────────▶ DWG → DXF → 모델 ────▶ React + Three.js
(React) + DBOS (LibreDWG + EZDXF, 레이어별) 2D CAD 에디터
...
브라우저에 DWG 평면도를 드롭합니다. 내구성이 있는 백그라운드 작업(durable background job)이 이를 변환하고, 기하학적 형상을 추출하며, 구조화된 건물 모델을 재구성합니다: 중심선으로서의 벽(walls), 해당 벽에 배치된 문(doors)과 창문(windows), 이름과 면적을 가진 폴리곤(polygons)으로서의 방(rooms). 여러분은 3D 뷰어, 완전한 2D CAD 에디터(스냅(snapping), 치수(dimensions), 실행 취소(undo), 축척 인쇄(print to scale)), 물량 산출(quantity take-offs), 그리고 다시 DWG로 내보내기 기능을 얻게 됩니다.
그리고 AI 에이전트가 모델을 읽고 편집할 수 있는 채팅 패널이 있습니다. 정말로 말이죠. 편집 사항은 데이터베이스에 반영되어 2D 및 3D에서 실시간으로 나타나며, 수동 편집과 마찬가지로 실행 취소(undo) 기록에 남습니다.
이 모든 것을 이해하기 전에 중요한 사실이 있습니다: DWG 파일에는 벽(walls)이 들어있지 않습니다. 선(lines)이 들어있을 뿐입니다. 이 프로젝트에서 흥미로운 모든 것은 이 두 문장 사이의 거리에 있습니다.
파트 1: DWG는 당신을 싫어하는 형식입니다
dwg는 약 30년의 버전을 가진 폐쇄적인 이진 형식 (binary format)입니다. 공식적인 사양 (spec)은 존재하지 않습니다. 오픈 소스 세계에는 단 하나의 진지한 해답이 있는데, 바로 libredwg입니다. 이는 기본적으로 거의 모든 것을 읽을 수 있으며, 하나의 버전 (r2000)에 대해서는 안정적으로 쓸 수 있습니다.
여기서 저를 몇 주간의 시간을 아껴준 두 가지 결정이 있었습니다:
libredwg를 라이브러리가 아닌 서브프로세스 (subprocess)로 실행하기. libredwg는 GPL 라이선스이지만 제 코드는 그렇지 않으며, 서브프로세스 경계는 라이선스를 깔끔하게 유지해 줍니다. 또한 이는 30년 된 형식 파서 (parser)에서 세그멘테이션 오류 (segfault)가 발생하더라도 제 서버가 아닌 자식 프로세스만 종료된다는 것을 의미합니다. 컨버터는 dwg를 텍스트와 동일한 데이터 모델인 dxf로 변환하며, 그 이후에는 ezdxf (훌륭한 Python 라이브러리)가 작업을 이어받습니다.
파일을 절대 믿지 말 것. 제가 가장 좋아하는 사례는 다음과 같습니다: dwg 파일은 도면 단위 (drawing units)를 선언하는 $INSUNITS 헤더를 포함하고 있습니다. 이 헤더는 끊임없이 거짓말을 합니다. 모든 좌표가 명백히 밀리미터 (millimetres)임에도 불구하고
파이프라인이 견고함을 증명하기 위해 인터넷에서 231개의 실제 dwg 파일을 수집했습니다: 건축(architectural), 구조(structural), 기계(mechanical), 전기(electrical) 등 r1.4부터 2025 버전까지의 모든 버전을 포함했습니다. 스모크 테스트 하네스(smoke test harness)는 각 파일을 타임아웃(timeout)이 설정된 격리된 서브프로세스(subprocess) 내에서 전체 파이프라인을 통해 실행하므로, 하나의 파일이 멈추더라도 전체 실행이 중단되지 않습니다.
모든 문제를 수정한 후 코퍼스(corpus)의 결과: 231개 파일, 0번의 충돌(crashes). "이 파일로부터는 많은 것을 재구성할 수 없었습니다"라는 정중한 응답은 허용되지만, 트레이스백(traceback)은 허용되지 않습니다.
파트 2: 수천 개의 선분에서 실제 벽으로
추출(extraction)을 거치면 일종의 '수프(soup)' 상태가 됩니다. 블록(blocks, 문짝이나 창틀과 같은 재사용 가능한 심볼)은 기본 엔티티(primitive entities)로 재귀적으로 분해되고, 호(arcs)는 현(chords)으로 샘플링되며, 폴리라인(polylines)은 에지(edges)로 분할됩니다. 결과물로 나오는 것은 수만 개의 가공되지 않은 선분들이며, 각 선분은 정확히 하나의 메타데이터, 즉 레이어 이름(layer name)만을 가지고 있습니다.
레이어 이름은 dwg가 가진 유일한 의미론적(semantics) 정보이며, 사람이 직접 입력하는 자유 형식 텍스트 필드입니다. 독일 건축가는 WAND나 MAUER라고 쓰고, 영국 건축가는 A-WALL이나 WALLS라고 쓰며, 모두가 서로 다르게 약어를 사용합니다. 따라서 분류(classification)는 다국어 힌트 리스트(hint list)를 기반으로 한 겸손한 부분 문자열 매칭(substring matching) 방식으로 이루어집니다:
_WALL_PATS = ("wall", "wand", "mauer", "gebäude", "gebaeude")
_DOOR_PATS = ("door", "tür", "tuer", "porte")
_WIN_PATS = ("window", "fenster", "wind")
동일한 트릭을 다른 힌트 리스트(flurstück, parcel, gelände, straße...)와 함께 사용하여 도면 전체가 건물의 평면도(floor plan)인지 대지 계획도(site plan)인지를 결정합니다. 이 두 가지는 완전히 다른 재구성(reconstruction) 방식이 필요하기 때문입니다. 그리고 파일에 인식 가능한 벽 레이어가 전혀 없는 경우(모든 것이 레이어 0에 담겨 있는 전형적인 상황), 파이프라인은 "문, 창문, 텍스트 또는 치수(dimension)가 아닌 모든 선분"을 대상으로 삼는 방식으로 전환하며, 결과가 근사치라는 경고를 첨부합니다. 자신감보다는 정직함을 택한 것입니다.
이제 진짜 문제가 등장합니다. 벽(wall) 레이어에서 벽은 결코 하나의 선으로 이루어져 있지 않습니다. 벽은 평행한 쌍으로 그려지며, 문이나 창문이 가로막는 곳마다 각 선분은 파편(fragments)으로 나뉩니다. 또한 수년 전 서로 다른 명령어로 인해 종종 반대 방향으로 그려지기도 합니다.
벽 레이어에 포함된 dwg 내용:
──────▶ ◀────────── ──▶ 방향이 섞인 4개의 파편
...
이러한 파편들을 병합하는 것은 그룹화 문제(grouping problem)입니다. 즉, 어떤 세그먼트(segments)들이 동일한 무한 직선상에 놓여 있는지를 찾아내는 것입니다. 저는 각 세그먼트를 양자화된 각도(4도 단위 버킷, 방향이 중요하지 않도록 180으로 나눈 나머지 값)와 원점으로부터의 수직 오프셋(perpendicular offset, 50mm 단위 버킷)을 기준으로 해싱(hash)합니다. 동일한 해시를 공유하는 세그먼트들은 공선(collinear) 관계에 있는 이웃들입니다.
저에게 가장 큰 가르침을 준 버그는 바로 여기에 있었습니다. 오프셋은 선의 법선 벡터(normal vector)를 따라 계산되는데, 오른쪽에서 왼쪽으로 그려진 세그먼트는 왼쪽에서 오른쪽으로 그려진 쌍둥이 세그먼트와 법선 방향이 반대가 됩니다. 이로 인해 오프셋의 부호가 뒤집히고, 결과적으로 동일한 벽의 두 부분이 서로 다른 해시 버킷에 담기게 됩니다. 해결책은 하나의 정규화(canonicalization) 과정입니다.
# 법선을 한쪽 반평면(half-plane)으로 뒤집어서, 동일한 직선상에서
# 반대 방향으로 그려진 세그먼트들(실제 dwg에서 매우 흔한,
# 문에 의해 분리된 벽의 두 부분)이 함께 해싱되도록 합니다.
...
이 수정 사항을 적용하기 전에는, 테스트 코퍼스(test corpus)의 절반 정도가 실제 건물에 있는 것보다 두 배나 많은 벽을 재구성해냈습니다.
각 그룹 내에서 병합은 1차원적인 문제가 됩니다. 모든 파편을 그룹의 방향으로 투영(project)하고, 구간(intervals)을 정렬한 뒤, 스윕(sweep)합니다. 맞닿아 있거나 겹치는 구간들은 하나로 합쳐집니다. 최대 2600mm(넉넉한 문 너비)까지의 간격(gaps)은 연결되어 기록되는데, 벽 선에서의 문 크기만한 구멍은 노이즈가 아니라 '문'이기 때문입니다. 그보다 큰 간격은 벽을 두 개로 분리합니다. 이렇게 기록된 간격들은 개구부 후보(opening candidates)로서 자유롭게 추출됩니다.
중심선(centerlines)이 확보되면, 분류는 기하학적(geometric)으로 이루어집니다. 경계 상자(bounding box)의 둘레에 _중점(midpoint)_이 맞닿아 있는 벽은 외벽(기본값 230mm 벽돌)이며, 그 외의 모든 것은 칸막이벽(100mm 블록)입니다. 끝점(endpoints) 대신 중점을 테스트하는 것이 중요한데, 그렇지 않으면 파사드(facade)에 끝이 닿아 있는 내부 벽이 외벽으로 격상될 수 있기 때문입니다.
그다음 문(doors)과 창문(windows)은 각각의 호스트(hosts)에 스냅(snap)됩니다. 문 또는 창문 레이어에서 각 세그먼트를 가져와, 500mm 수직 허용 오차(perpendicular tolerance) 내에서 가장 가까운 벽을 찾고, 그 중점을 벽의 축(axis) 위로 투영(project)합니다. 그 투영 결과가 바로 문(door)의 위치이며, 벽을 따라 center_mm이라는 하나의 숫자로 저장됩니다. 동일한 유형과 크기(50mm 단위로 반올림)를 가진 개구부(openings)는 동일한 스케줄 마크(schedule mark)를 공유하며, 이를 통해 D1, D2, W1과 같은 기호가 숙련된 제도사가 작업하는 것과 정확히 동일한 방식으로 할당됩니다.
아무도 직접 그리지 않은 모든 치수(층고 3000, 문 높이 2100, 창문 문턱 900)는 명시된 시공 기본값(construction default)이며, UI가 파일에 명시된 것처럼 속이는 대신 "가정됨(assumed)"이라고 표시할 수 있도록 모델의 경고(warnings) 항목에 추가됩니다.
파트 3: 방은 평면 그래프(planar graph)의 면(faces)이다
방(rooms)은 마법처럼 느껴지지만, 실제로는 그래프 이론(graph theory)에 기반한 부분입니다.
벽의 중심선이 확보되면 평면도(floor plan)는 평면 분할(planar subdivision)이 됩니다. 즉, 선들이 평면을 면(faces)으로 나누며, 경계가 있는 면들이 곧 방이 됩니다. 이를 추출하는 것은 전형적인 계산 기하학(computational geometry) 연습 문제입니다.
1. 모든 교차점에서 모든 세그먼트를 분할하고, 노드를
60mm 그리드에 스냅(snap)합니다. 2. 모든 노드에서 가장 급격한 시계 방향
회전을 하며 하프 엣지(half-edges)를 따라 이동합니다.
...
2단계가 우아한 부분입니다. 모든 유향 엣지(directed edge)로부터 가능한 한 가장 급격하게 시계 방향으로 계속 회전하면 정확히 하나의 최소 면(minimal face)을 추적하게 되며, 평면도의 모든 내부 면은 정확히 한 번씩 추적됩니다. 재귀(recursion)나 플러드 필(flood fill) 없이, 오직 각도(angles)만으로 가능합니다.
3단계는 제가 가장 좋아하던 기하학적 버그를 숨겨주었습니다. 모든 방과 더불어 하나의 추가적인 다각형이 생성되는데, 바로 외부에서 추적된 건물 전체의 외곽 경계(outer boundary)입니다. 저의 첫 번째 휴리스틱(heuristic)은 경계 상자(bounding box)의 일정 비율보다 큰 면은 모두 제거하는 방식이었습니다. 모든 직사각형 테스트 건물에서는 잘 작동했지만, L자형 평면도가 등장하자 문제가 생겼습니다. L자형의 외곽 면은 경계 상자 면적에 훨씬 못 미치기 때문에, 임계값(threshold)을 통과해 버렸고 UI에는 전체 점유 면적을 덮는 거대한 유령 방(phantom room)으로 나타났습니다. 올바른 규칙은 측정(metric)이 아닌 위상(topological) 기반이어야 합니다. 연결된 배치(connected arrangement)에서 무한 면(unbounded face)은 다른 모든 것을 둘러싸고 있으므로, 항상 단 하나의 가장 큰 면이 됩니다. 최대 면을 버리고 나머지를 유지하세요. 튜닝할 임계값도 없고, 이를 깨뜨리는 형태도 없습니다.
살아남은 요소들에 이름을 붙이는 것은 거의 부차적인 작업입니다. 평면도에는 텍스트 레이블("kitchen", "büro 2.13")이 포함되어 있으므로, 각 레이블에 대해 각 방의 면(face)을 대상으로 점-내-다각형(point-in-polygon) 검사를 실행하면 이름이 제자리를 찾아갑니다. 레이블이 없는 방은 "room 7"이 되는데, 이는 적어도 정직한 방식입니다.
이 두 파트에 걸쳐 일어난 일에 대해 잠시 멈춰 생각해 볼 가치가 있습니다. 입력값은 순서가 없는 익명의 선분(line segments) 더미였습니다. 하지만 출력값은 다음과 같습니다: 유형과 재질을 가진 7개의 벽, 자신이 어느 벽의 어느 위치에 있는지 알고 있는 6개의 문과 3개의 창문, 이름·면적·둘레를 가진 4개의 방, 그리고 주요 벽 축(wall axes)에서 유도된 구조 그리드(structural grid). 파일의 그 어떤 것에도 이 내용이 명시되어 있지 않았습니다. 이 모든 것은 기하학(geometry) 안에 잠재(latent)되어 있었습니다.
파트 4: 모델은 덩어리가 아니라 행(rows)이다
쉬운 설계 방식은 이렇습니다: ETL이 JSON 파일을 생성하면, 뷰어(viewer)가 그 JSON 파일을 읽는 것입니다. 그러면 끝입니다.
저는 그렇게 하지 않았고, 그것이 프로젝트에서 가장 중요한 단 하나의 결정이 되었습니다. 추출(extraction) 과정은 모델을 엔티티 행(entity rows)으로서 데이터베이스에 기록합니다:
walls (id, x1, y1, x2, y2, thickness_mm, height_mm, type, material)
openings (id, mark, type, wall_id, center_mm, width_mm, height_mm, sill_mm)
rooms (id, name, polygon, floor_finish, wall_finish)
...
하나의 엔드포인트는 행(rows)들을 뷰어가 소비하는 모델 JSON으로 조립합니다. 또 다른 엔드포인트는 편집된 모델을 Pydantic 스키마(schemas)에 따라 검증하고 이를 다시 행(rows)으로 기록합니다.
이 방식이 무엇을 가져다주는지 주목하십시오. 편집은 단순히 행(rows)을 업데이트하는 작업일 뿐입니다. DWG 내보내기(export)는 단순히 행(rows)을 읽고 DXF 엔티티(entities)(실제 DIMENSION 및 TEXT 엔티티, mitered wall outlines)를 생성하는 작업입니다. 그리고 미리 암시해 두자면, 모델을 편집하는 AI 도구는 단순히 검증된 저장 경로를 호출하는 또 다른 호출자(caller)일 뿐입니다.
문(door)은 그림 속의 구멍이 아닙니다. 그것은 자신이 어느 벽에 위치하는지, 그리고 얼마나 떨어져 있는지를 알고 있는 하나의 행(row)입니다. 이후의 모든 프로세스는 그로부터 파생됩니다.
파트 5: CAD 에디터는 주로 세 가지 문제로 이루어져 있다
나는 먼저 구매 옵션을 조사했습니다. 상용 브라우저 CAD SDK는 실제로 DWG를 편집할 수 없거나(마크업 기능이 있는 뷰어 수준), 내가 필요하지 않은 일반적인 DWG 범위를 다루는 데 연간 약 $7,500부터 시작했습니다. 나는 오직 내가 재구성한 모델을 편집할 필요가 있었을 뿐입니다. 그래서 나는 순수 Canvas 2D 컨텍스트(context) 위에 직접 구축했습니다.
전문적인 느낌을 주는 CAD 에디터는 주로 세 가지 문제로 요약됩니다: 렌더링 속도(rendering speed), 스냅(snapping), 그리고 실행 취소(undo)입니다.
렌더링(rendering). 나의 첫 번째 버전은 마우스 이동(mousemove)이 발생할 때마다 모든 것을 다시 그렸습니다. 963개의 벽이 있는 도면에서는 프레임당 1154ms가 소요되었습니다. 초당 1프레임 수준이었습니다. 해결책은 그래픽 분야에서 가장 오래된 기술입니다: 변화 빈도에 따라 장면(scene)을 분리하는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기