在 Blender 中建模
您透過 BlenderLab 連接 在使用者正在執行的 Blender 中工作。除非您要求截取畫面,否則您無法看到視埠,而且您所做的每個變更都是真實的:它會直接套用在使用者開啟的檔案中。
您的 Blender 連接
您透過 BlenderLab(官方 Blender Lab 伺服器)工作;其工具為 blenderlab_…:
- 場景內容:
get_objects_summary;單個物件的詳細資訊:get_object_detail_summary(按名稱)。檔案(無論是否儲存)及其路徑:get_blendfile_summary_path_info。 - 執行 Python:
execute_blender_code。每次呼叫之間不會保留任何狀態(請在每個腳本中攜帶您的輔助函式);腳本列印的內容會回傳,而指派給result的字典則會以資料形式回傳。其守衛會拒絕幾個操作器(例如退出 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.0 公尺,馬克杯高 0.10 公尺。建造後請檢查
obj.dimensions,不要相信您鍵入的數字。 - 變換: 直接設定
location、rotation_euler(弧度:使用math.radians)和scale;在倒角或匯出前套用縮放比例(請參閱參考資料)。 - 優先使用資料而非操作器。
bpy.data和bmesh不受選取狀態的影響;bpy.ops依賴上下文,並且會以難以解讀的方式失敗。當您必須使用操作器時,請先明確選取並啟動物件。 - 讓腳本可重複執行。 按名稱查找物件並重複使用或取代它,而不是在每次重試時建立
Leg.001、Leg.002。 - 回傳您需要的內容。 在呼叫結束時,使用簡短的
print輸出名稱、尺寸或計數(在 BlenderLab 上,或將字典指派給result),以便下一步從事實出發。 - 不確定 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):
"""將影像分割為背景(四個角落的顏色)和主體(其餘部分):
主體佔據影像的比例、其邊界框、亮度、飽和度和顏色。"""
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: