Blender에서 모델링하기
사용자는 BlenderLab 연결을 통해 실행 중인 Blender 내부에서 작업합니다. 사용자는 캡처를 요청하지 않는 한 뷰포트를 볼 수 없으며, 사용자가 만드는 모든 변경 사항은 실제로 적용됩니다. 즉, 사용자가 열어 놓은 파일에 바로 반영됩니다.
사용자의 Blender 연결
사용자는 BlenderLab(공식 Blender Lab 서버)을 통해 작업합니다. 해당 도구들은 blenderlab_… 형식입니다.
- 씬에 있는 것:
get_objects_summary; 하나의 객체를 상세히:get_object_detail_summary(name기준). 저장되었거나 저장되지 않은 파일 및 경로:get_blendfile_summary_path_info. - Python 실행:
execute_blender_code. 호출 사이에 아무것도 유지되지 않습니다(도우미 함수는 각 스크립트에 포함시켜야 함). 스크립트가 출력하는 내용이 반환되고,result에 할당된 dict가 데이터로 반환됩니다. 가드는 몇 가지 연산자(Blender 종료, 공장 설정 복원)를 거부합니다. - 3D 뷰포트 보기:
get_screenshot_of_area_as_image(area_ui_type: "VIEW_3D"사용) (전체 창:get_screenshot_of_window_as_image); 객체를 가리키려면:jump_to_view3d_object_by_name. - Python API 확인:
get_python_api_docs(bpy.types.BevelModifier와 같은 식별자),search_api_docs; 매뉴얼:search_manual_docs. - 빠른 렌더링:
render_thumbnail_to_path,render_viewport_to_path; 이미지는 Blender의 임시 폴더로 이동하고 결과로 실제 경로를 제공합니다. - 내보내기: 코드 (
bpy.ops.export_scene.gltf,bpy.ops.export_scene.fbx). - 에셋 라이브러리 및 3D 생성기: 없음. 객체를 직접 만들거나 사용자에게 가져올 파일을 요청하세요.
이러한 도구를 사용할 수 없으면 중단하고 그 사실을 알리세요. 사용자가 BlenderLab 연결(도구 → 연결)을 설치하고, 이 에이전트에 부여한 후, 애드온 서버가 시작된 Blender를 열어야 합니다. 작업을 수행할 수 없는데 완료된 것처럼 설명하지 마세요.
반복 과정
매번 이 순서대로 작업하세요. 먼저 보고 나중에 확인하는 단계를 건너뛰면 아무도 모르는 사이에 모델이 잘못될 수 있습니다.
- 살펴보기. 씬 도구로 현재 있는 것(컬렉션, 객체, 활성 카메라)을 확인합니다. 건드릴 객체에 대해서는 객체 상세 도구를 사용합니다. 빈 씬이라고 가정하지 마세요.
- 부품별 계획. 모델을 크기(미터 단위)와 위치를 포함한 명명된 부품으로 나눕니다("좌석 0.45 × 0.45 × 0.04, z = 0.45에 위치"). 몇 개 이상의 객체를 만들기 전에 계획을 짧은 목록 하나로 제시하세요.
- 단계별 구축.
execute_blender_code호출당 두세 개의 관련 부품(벽, 주추 및 지붕; 문과 창문), 30-90줄. 까다로운 부분(부울 연산, 곡면)이거나 실패 직후에는 하나의 부품만 호출하세요. 200줄짜리 스크립트의 역추적은 정보가 거의 없고 씬을 반쯤 변경된 상태로 남깁니다. - 중간 지점에서 확인. 주요 볼륨이 서 있을 때, 세부 사항이 들어간 후, 마지막에 3D 뷰포트를 캡처하고 계획과 실제로 비교하세요: 비율, 부품이 닿아야 할 위치에 닿았는지, 떠 있는 것은 없는지, 다른 것 안에 있는 것은 없는지. 캡처 사이에는 코드가 출력한 숫자로 확인하세요. 진행하기 전에 수정하세요. 캡처가 사용자에게 보이지 않는 것으로 표시되면, 모델이 이미지를 볼 수 없는 것입니다. 대신 숫자(크기, 위치, 개수)로 확인하고, 볼 수 없다고 말하세요.
- 보고. 만든 것, 객체 이름, 크기, 요청된 것과 다른 점을 말하세요.
사용자의 단계 예산
작업은 사용자에게 제한된 도구 라운드(사용자가 늘리지 않으면 15회)를 제공하며, 모든 호출은 하나의 라운드를 사용합니다: 스킬 열기, 씬 요약, 빌드 호출, 캡처.
- 필요한 스킬을 한 번에 함께 열고, 사용할 경우에만 참조 파일을 여세요. 이 대화에서 이미 연 것은 다시 열지 마세요.
- 첫 번째 빌드 호출 전에 예산에 맞춰 단계를 계획하세요: 약 1 라운드는 살펴보기, 8-9 라운드는 빌드, 3회 캡처, 1회 수정 예비. 가장 눈에 띄는 부분이 먼저 오도록 순서를 정하세요.
- 캡처와 요약이 서로 의존하지 않으면 한 라운드에 둘 다 요청하세요.
- 요청이 맞지 않으면 계획에서 그렇게 말하고, 일관된 부분을 잘 완료한 후 정확히 남은 목록을 남겨 사용자가 "계속"이라고만 말하면 되게 하세요. 절대 작업 중간에 멈추지 마세요.
대부분의 실패를 방지하는 규칙
- 사용자가 만든 것이 아니면 삭제하거나 덮어쓰지 마세요. 깨끗한 씬을 얻기 위해 "모두 선택, 삭제"는 안 됩니다.
read_factory_settings도 안 됩니다. 기본 큐브가 방해가 되면 요청하거나 그 옆에서 작업하세요. - 작업물은 모델 이름을 딴 별도의 컬렉션에 넣고, 모든 객체와 메시에 실제 이름을 지정하세요(
Chair_Leg_FL,Cube.004아님). 이후 단계에서 이름으로 객체를 찾습니다. - 실제 스케일. 1 유닛 = 1 미터. 문은 2.0m, 머그컵은 0.10m입니다. 입력한 숫자를 믿지 말고 구축 후
obj.dimensions를 확인하세요. - 변환:
location,rotation_euler(라디안:math.radians사용) 및scale을 직접 설정하세요. 베벨이나 내보내기 전에 스케일을 적용하세요(참조 자료 참조). - 연산자보다 데이터를 선호하세요.
bpy.data와bmesh는 선택 상태에 관계없이 작동합니다.bpy.ops는 컨텍스트에 의존하며 읽기 어려운 방식으로 실패합니다. 연산자를 사용해야 하는 경우 먼저 객체를 명시적으로 선택하고 활성화하세요. - 스크립트를 재실행 가능하게 만드세요. 매번
Leg.001,Leg.002를 생성하는 대신 이름으로 객체를 찾아 재사용하거나 교체하세요. - 필요한 것을 다시 보내세요. 호출을 이름, 크기 또는 개수에 대한 짧은
print(BlenderLab에서, 또는result에 할당된 dict)로 끝내서 다음 단계가 사실에서 시작하도록 하세요. - API 이름이 확실하지 않습니까? Blender의 Python API는 버전 간에 변경됩니다. 추측하기 전에 연결의 API 도구로 확인하고, 시작할 때
bpy.app.version_string에서 버전을 한 번 읽으세요.
기본 요소, bmesh, 모디파이어, 스무딩, 곡선, 배열 및 부모-자식 관계에 대한 패턴은 references/bpy-patterns.md에 있습니다. 작업의 첫 번째 스크립트를 작성하기 전에 열어보세요.
모양을 만드는 방법 선택하기
| 모양 | 접근법 |
|---|---|
| 상자, 원통, 가구, 건축, 소품 | 기본 요소, 크기 조정 및 배치; 모서리에 베벨 모디파이어 |
| 반복되는 모든 것(울타리, 계단, 기어 이빨) | 하나의 부품 + 배열 모디파이어, 또는 동일한 메시를 연결하는 루프 |
| 둥글거나 선반 가공된 물체(꽃병, 유리, 병, 바퀴) | 프로파일 + 스크류 모디파이어, 또는 베벨이 있는 곡선 |
| 파이프, 케이블, 손잡이, 레일 | bevel_depth가 있는 곡선 |
| 대칭적인 것(차량, 캐릭터 블록아웃) | 모델의 절반 + 미러 모디파이어 |
| 텍스트, 로고, 동전, 명판 | 텍스트 또는 곡선 객체, 돌출 후 메시로 변환 |
| 부드럽고 유기적인 형태 | 기본 요소로 블록아웃 + 서브디비전 서피스; 이러한 방식으로 조각 디테일이 제한된다고 정직하게 말하세요 |
반복하는 동안 모디파이어는 적용되지 않은 상태로 유지하세요(편집 가능 상태 유지)하고 보고서에 그렇게 명시하세요. 내보내기 또는 이후 단계에서 실제 지오메트리가 필요할 때만 적용하세요.
참조 이미지로 작업하기
- 먼저 보이는 것을 설명하세요: 부품, 부품 간의 비율, 반복되는 것의 개수. 그 목록을 바탕으로 만드세요.
- 이미지가 결정하며, 이미지를 위해 만든 숫자가 아닙니다. 비율을 비율로 읽으십시오(가로 대 세로 대 깊이; 전체 높이에서 지붕이 차지하는 비율; 벽에 대한 문의 너비). 하나의 측정값(사용자의 값 또는 합리적인 실제 세계 크기)을 선택하여 스케일을 고정하고, 다른 모든 크기는 비율에서 도출하세요. 요청 텍스트에 이미지와 모순되는 크기가 있으면 이미지를 따르고 그렇게 말하세요.
- 참조로부터 프롬프트나 브리프를 작성하라는 요청을 받으면 모양, 비율, 색상 및 개수를 설명하세요. 미터 단위의 크기를 임의로 만들지 마세요. 브리프로부터 만드는 사람이 그림 대신 그 크기를 따르게 됩니다.
- 참조가 보여주는 것을 만드세요. 사용자가 요청하지 않는 한, 참조에 없는 베이스, 소품 또는 장식을 추가하지 마세요.
- 블록아웃 후, 그리고 보고하기 전에 다시, 뷰를 참조의 각도로 맞추고 캡처하여 부품별로 비교하세요: 실루엣, 비율, 각 부품의 위치, 색상. 다른 점을 수정하세요. 수정할 수 없는 것은 보고서에 나열하세요. 두세 번의 수정 라운드를 예상하세요. 이는 정상이며 실패가 아닙니다.
- 프레이밍, 조명 및 색상은 눈대중이 아닌 측정됩니다: 아래 "최종 확인"을 참조하세요. 파일 이름을 제공하면 사용자의 렌더를 참조 파일과 비교합니다.
- 이전 메시지에 첨부된 참조는 더 이상 사용자에게 보이지 않을 수 있습니다. 볼 수 없으면 그렇게 말하고 기억으로 작업하는 대신 다시 요청하세요.
최종 확인 (항상 마지막 호출)
사용자는 렌더를 보지 않습니다. 렌더 도구는 파일을 쓰고 경로를 제공하며, 뷰포트 캡처는 씬의 조명을 보여주지 않습니다. 의도한 대로가 아니라 실제로 나온 것을 보고하는 모델은 깨닫지 못한 채 흰색 또는 검은색 이미지를 전달합니다. 따라서 모델을 만들거나 변경하는 모든 작업의 마지막 execute_blender_code 호출은 final_check이며, 그 출력은 그대로 보고서에 포함됩니다.
이것은 씬의 카메라로 작은 렌더를 만들고, 배경(네 모서리의 색상)과 모델(나머지)로 분할한 후 다음을 출력합니다: 모델이 프레임을 얼마나 채우는지, 밝기, 채도 및 주요 색상, 재질이 없는 객체, 기하 구조가 손상된 객체(잘못된 정점에 연결된 면), 그리고 잘못된 각 항목에 대해 하나의 PROBLEM: 줄. 참조 파일 이름이 주어지면 동일한 방식으로 해당 이미지를 측정하고 프레이밍, 밝기, 색상 및 실루엣의 견고성을 비교합니다.
import bpy, os, re
import numpy as np
from mathutils import Vector
from bpy_extras.object_utils import world_to_camera_view
BACKDROP_WORDS = ("ground", "backdrop", "floor", "sky", "shadow")
def subject_stats(path, size=128):
"""An image split into background (the colors of its four corners) and subject (the rest):
how much of the image the subject takes, its box, its brightness, saturation and colors."""
img = bpy.data.images.load(path, check_existing=False)
w, h = img.size
sw, sh = (size, max(8, round(size * h / w))) if w >= h else (max(8, round(size * w / h)), size)
img.scale(sw, sh)
px = np.empty(len(img.pixels), dtype=np.float32)
img.pixels.foreach_get(px)
bpy.data.images.remove(img)
rgb = px.reshape(sh, sw, 4)[:, :, :3] # row 0 is the bottom of the image
cy, cx = max(2, sh // 8), max(2, sw // 8)
corners = np.concatenate([rgb[:cy, :cx].reshape(-1, 3), rgb[:cy, -cx:].reshape(-1, 3),
rgb[-cy:, :cx].reshape(-1, 3), rgb[-cy:, -cx:].reshape(-1, 3)])
# Background = close to one of the corner colors (a gradient or a soft shadow still counts).
q = np.unique(np.round(corners * 8).astype(int), axis=0)
flat = rgb.reshape(-1, 3)
dist = np.min(np.abs(flat[:, None, :] * 8 - q[None, :, :]).max(axis=2), axis=1)
subject = (dist > 1.0).reshape(sh, sw)
share = float(subject.mean())
whole = flat @ np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
out = {"size": (w, h), "image_brightness": round(float(whole.mean()), 3), "subject_pct": round(100 * share, 1)}
if share < 0.005:
return out
ys, xs = np.nonzero(subject)
out["box"] = {"width": round(float(xs.max() - xs.min() + 1) / sw, 2), "height": round(float(ys.max() - ys.min() + 1) / sh, 2),
"center": (round(float(xs.max() + xs.min() + 1) / 2 / sw, 2), round(float(ys.max() + ys.min() + 1) / 2 / sh, 2))}
sub = rgb[subject]
lum = sub @ np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
mx, mn = sub.max(axis=1), sub.min(axis=1)
out["brightness"] = round(float(lum.mean()), 3)
out["saturation"] = round(float(np.where(mx > 1e-4, (mx - mn) / np.maximum(mx, 1e-4), 0.0).mean()), 3)
out["burnt_pct"] = round(100 * float((lum > 0.97).mean()), 1)
keys = (np.clip((sub * 5.999).astype(int), 0, 5) * np.array([36, 6, 1])).sum(axis=1)
counts = np.bincount(keys, minlength=216)
out["colors"] = [("#%02x%02x%02x" % tuple(int(round(float(c) * 255)) for c in sub[keys == k].mean(axis=0)),
int(round(100 * counts[k] / len(keys)))) for k in counts.argsort()[::-1][:5] if counts[k]]
return out
# Where a HydraOps installation keeps its data (storage/uploads hangs from it): the data
# folder it was told (HYDRA_DATA_DIR), the installer's folder per system, and a clone of
# the repository, which keeps its data inside itself when run from source.
UPLOAD_ROOTS = [os.environ.get("HYDRA_DATA_DIR", ""),
os.path.join(os.environ.get("APPDATA", ""), "HydraOps", "data"),
os.path.expanduser("~/Library/Application Support/HydraOps/data"),
os.path.expanduser("~/.config/HydraOps/data"),
os.path.expanduser("~/HydraOps")]
UPLOAD_FOLDERS = [os.path.join(r, "storage", "uploads") for r in UPLOAD_ROOTS if r]
def find_reference(file_name):
"""The path of an image attached in THIS conversation, named as the conversation shows it:
"storage/uploads/1791118524833-house.png", the bare "1791118524833-house.png", or a full path.
None when it is not there."""
raw = str(file_name or "").replace("\\", "/").strip()
name = os.path.basename(raw)
if not name or os.path.splitext(name)[1].lower() not in (".png", ".jpg", ".jpeg", ".webp"):
return None
if os.path.isabs(raw) and os.path.isfile(raw):
return os.path.normpath(raw)
for folder in UPLOAD_FOLDERS:
path = os.path.join(folder, name)
if os.path.isfile(path):
return path
# The stored name starts with a timestamp that is easy to copy with one digit wrong:
# fall back to the newest upload with the same original name after the timestamp.
m = re.match(r"^\d{10,}-(.+)$", name)
if m:
pattern = re.compile(r"^\d{10,}-" + re.escape(m.group(1)) + "$")
newest = None
for folder in UPLOAD_FOLDERS:
try:
entries = os.listdir(folder)
except OSError:
continue
for entry in entries:
if pattern.match(entry):
path = os.path.join(folder, entry)
if newest is None or os.path.getmtime(path) > os.path.getmtime(newest):
newest = path
return newest
return None