https://note.com/zionad2017/n/ndb6dbdbc844a
import bpy
import re
import os
import base64
import webbrowser
import datetime
from bpy.types import Operator, Panel, PropertyGroup, UIList
from bpy.props import StringProperty, PointerProperty, CollectionProperty, IntProperty
from bpy_extras.io_utils import ImportHelper
# ===================================================================
# 【定義】
# ===================================================================
ADDON_TITLE = "コンテナ builder"
PREFIX_VAL = "BUILDER_1001"
ADDON_CATEGORY = "コンテナ builder"
# ===================================================================
# 【名前初期値設定】
# ===================================================================
# 生成されるコンテナ名とサイドバー表示名の初期パターンを設定します。
# 「{datetime}」と記述された部分は、自動的に「YYYY_MMDD_HHMM」形式の現在日時に置換されます。
DEFAULT_NAME_TITLE = "コンテナ {datetime}"
DEFAULT_NAME_CATEGORY = "コンテナ {datetime}"
# ===================================================================
# 【リンク定義】
# ===================================================================
# 1番はユーザー指定のカスタムリンク用に空き、2〜4番に下記定義を設定
LINK_NAME_2 = "コンテナ builder"
LINK_URL_2 = "<https://note.com/zionad2017/n/ndb6dbdbc844a>"
LINK_NAME_3 = "posfie"
LINK_URL_3 = "<https://posfie.com/@timekagura/t/zionad2022?sort=0>"
LINK_NAME_4 = "zionadchat"
LINK_URL_4 = "<https://x.com/zionadchat>"
# =========================================================
# コンテナ生成ビルダー アドオン (Blender 5以降専用)
# =========================================================
bl_info = {
"name": "コンテナ builder",
"author": "AI",
"version": (3, 3, 0),
"blender": (5, 0, 0),
"location": "View3D > Sidebar > コンテナ builder",
"description": "エラーが起きないようにBase64変換してコンテナアドオンを生成します",
"category": "3D View",
}
# =========================================================
# プロパティ取得ヘルパー
# =========================================================
def get_props(context):
prop_name = f"{PREFIX_VAL.lower()}_props"
return getattr(context.scene, prop_name, None)
# =========================================================
# カスタムゲッター/セッター
# =========================================================
def get_dynamic_default_title(self):
if not self.get("container_title_stored", ""):
# 日時指定フォーマット: YYYY_MMDD_HHMM
now_str = datetime.datetime.now().strftime("%Y_%m%d_%H%M")
return DEFAULT_NAME_TITLE.replace("{datetime}", now_str)
return self.get("container_title_stored", "")
def set_dynamic_default_title(self, value):
self["container_title_stored"] = value
def get_dynamic_default_category(self):
if not self.get("container_category_stored", ""):
# 日時指定フォーマット: YYYY_MMDD_HHMM
now_str = datetime.datetime.now().strftime("%Y_%m%d_%H%M")
return DEFAULT_NAME_CATEGORY.replace("{datetime}", now_str)
return self.get("container_category_stored", "")
def set_dynamic_default_category(self, value):
self["container_category_stored"] = value
# =========================================================
# データ構造
# =========================================================
class ZIONAD_BuilderItem(PropertyGroup):
name: StringProperty()
text_ptr: PointerProperty(name="Text Data", type=bpy.types.Text)
class ZIONAD_BuilderProps(PropertyGroup):
items: CollectionProperty(type=ZIONAD_BuilderItem)
active_index: IntProperty(default=0)
container_title: StringProperty(
name="コンテナ名",
description="生成するアドオンの正式名称 (ADDON_TITLE)",
get=get_dynamic_default_title,
set=set_dynamic_default_title
)
container_category: StringProperty(
name="サイドバー表示名",
description="3Dビューのサイドバーに表示されるタブ名 (ADDON_CATEGORY)",
get=get_dynamic_default_category,
set=set_dynamic_default_category
)
custom_link_name: StringProperty(
name="カスタムリンク名",
description="コンテナ内に表示する独自の追加リンク1の名前 (空欄の場合は非表示)",
default=""
)
custom_link_url: StringProperty(
name="カスタムリンクURL",
description="独自の追加リンク1のURL",
default=""
)
# =========================================================
# オペレーター: 追加・削除・一括インポート・アンインストール
# =========================================================
class ZIONAD_OT_BuilderAddClipboard(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.add_clipboard"
bl_label = "クリップボードから取り込む"
def execute(self, context):
text = context.window_manager.clipboard
if not text or len(text.strip()) == 0:
self.report({'WARNING'}, "クリップボードが空です。")
return {'CANCELLED'}
props = get_props(context)
if not props: return {'CANCELLED'}
match = re.search(r'["\']name["\']\s*:\s*["\']([^"\']+)["\']', text)
base_name = match.group(1) if match else f"Clipboard_Addon_{len(props.items)+1}"
t = bpy.data.texts.new(name=f"{base_name}.py")
t.write(text)
item = props.items.add()
item.name = base_name
item.text_ptr = t
props.active_index = len(props.items) - 1
self.report({'INFO'}, f"{base_name} を追加しました")
return {'FINISHED'}
class ZIONAD_OT_BuilderAddFile(Operator, ImportHelper):
bl_idname = f"{PREFIX_VAL.lower()}.add_file"
bl_label = "フォルダから.pyを選ぶ(複数可)"
bl_description = "複数の.pyファイルを選択して一度に取り込みます (Shift/Ctrlキーで複数選択)"
filename_ext = ".py"
filter_glob: StringProperty(default="*.py", options={'HIDDEN'})
files: CollectionProperty(
name="File Path",
type=bpy.types.OperatorFileListElement,
)
directory: StringProperty(
subtype='DIR_PATH',
)
def execute(self, context):
props = get_props(context)
if not props: return {'CANCELLED'}
added_count = 0
if self.files:
for file_elem in self.files:
name = file_elem.name
if not name:
continue
filepath = os.path.join(self.directory, name)
try:
with open(filepath, 'r', encoding='utf-8') as f:
text = f.read()
except Exception as e:
self.report({'ERROR'}, f"ファイルの読み込み失敗: {name} - {e}")
continue
t = bpy.data.texts.new(name=name)
t.write(text)
item = props.items.add()
item.name = name.replace('.py', '')
item.text_ptr = t
added_count += 1
if added_count > 0:
props.active_index = len(props.items) - 1
self.report({'INFO'}, f"{added_count}個のファイルを追加しました")
else:
self.report({'WARNING'}, "ファイルが追加されませんでした")
return {'CANCELLED'}
return {'FINISHED'}
class ZIONAD_OT_BuilderImportFromContainerText(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.import_container_text"
bl_label = "コンテナテキストから一括復元"
bl_description = "現在テキストエディタで開いているコンテナコードから、収録データをビルダーへ一括で取り込みます"
def execute(self, context):
props = get_props(context)
if not props: return {'CANCELLED'}
text_block = None
for area in context.screen.areas:
if area.type == 'TEXT_EDITOR':
text_block = area.spaces.active.text
break
if not text_block:
self.report({'WARNING'}, "テキストエディタにテキストが開かれていません。")
return {'CANCELLED'}
code_str = text_block.as_string()
match = re.search(r'STORED_CODES\s*=\s*\[(.*?)^\s*\]', code_str, re.DOTALL | re.MULTILINE)
if not match:
self.report({'WARNING'}, "テキスト中に 'STORED_CODES' が検出できませんでした。")
return {'CANCELLED'}
local_scope = {}
try:
exec(match.group(0), {}, local_scope)
except Exception as e:
self.report({'ERROR'}, f"解析エラー: {e}")
return {'CANCELLED'}
stored_codes = local_scope.get("STORED_CODES")
if not stored_codes or not isinstance(stored_codes, list):
self.report({'WARNING'}, "データリストの展開に失敗しました。")
return {'CANCELLED'}
added_count = 0
for item in stored_codes:
name = item.get("name")
code_b64 = item.get("code_b64")
if not name or not code_b64:
continue
try:
raw_code = base64.b64decode(code_b64).decode('utf-8')
except Exception as e:
self.report({'ERROR'}, f"デコードエラー ({name}): {e}")
continue
text_name = f"{name}.py"
if text_name in bpy.data.texts:
t = bpy.data.texts[text_name]
t.clear()
t.write(raw_code)
else:
t = bpy.data.texts.new(text_name)
t.write(raw_code)
exists = False
for b_item in props.items:
if b_item.name == name:
b_item.text_ptr = t
exists = True
break
if not exists:
new_item = props.items.add()
new_item.name = name
new_item.text_ptr = t
added_count += 1
if added_count > 0:
props.active_index = len(props.items) - 1
self.report({'INFO'}, f"コンテナから {added_count} 個のアドオンデータをビルダーに復元しました")
else:
self.report({'WARNING'}, "復元可能なアドオンデータが見つかりませんでした。")
return {'FINISHED'}
class ZIONAD_OT_BuilderRemove(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.remove_item"
bl_label = "選択した項目を削除"
def execute(self, context):
props = get_props(context)
if not props or not props.items: return {'CANCELLED'}
idx = props.active_index
item = props.items[idx]
if item.text_ptr:
bpy.data.texts.remove(item.text_ptr)
props.items.remove(idx)
props.active_index = max(0, min(idx, len(props.items) - 1))
return {'FINISHED'}
class ZIONAD_OT_BuilderMoveItem(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.move_item"
bl_label = "順番を移動"
bl_description = "選択したアドオンの順番を上下に移動します"
direction: StringProperty()
def execute(self, context):
props = get_props(context)
if not props or not props.items: return {'CANCELLED'}
idx = props.active_index
new_idx = idx - 1 if self.direction == 'UP' else idx + 1
if 0 <= new_idx < len(props.items):
props.items.move(idx, new_idx)
props.active_index = new_idx
return {'FINISHED'}
class ZIONAD_OT_BuilderSortItems(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.sort_items"
bl_label = "名前順にソート"
bl_description = "追加されたアドオンをアルファベット順に並べ替えます"
def execute(self, context):
props = get_props(context)
if not props or len(props.items) < 2:
return {'CANCELLED'}
items_data = [(item.name, item.text_ptr) for item in props.items]
items_data.sort(key=lambda x: x[0].lower())
props.items.clear()
for name, txt in items_data:
new_item = props.items.add()
new_item.name = name
new_item.text_ptr = txt
props.active_index = 0
self.report({'INFO'}, "アドオンを名前順にソートしました")
return {'FINISHED'}
class ZIONAD_OT_OpenUrl(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.open_url"
bl_label = "リンクを開く"
url: StringProperty()
def execute(self, context):
webbrowser.open(self.url)
return {'FINISHED'}
class ZIONAD_OT_RemoveBuilder(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.remove_builder"
bl_label = "Unregister Builder"
bl_description = "このビルダーアドオンを無効化し、UIから消去します"
def execute(self, context):
bpy.app.timers.register(unregister, first_interval=0.01)
return {'FINISHED'}
# =========================================================
# オペレーター: コンテナコードの生成と出力 (メイン処理)
# =========================================================
class ZIONAD_OT_BuilderGenerate(Operator):
bl_idname = f"{PREFIX_VAL.lower()}.generate"
bl_label = "コンテナコードを出力"
def execute(self, context):
props = get_props(context)
if not props or not props.items:
self.report({'WARNING'}, "コードが一つも追加されていません。")
return {'CANCELLED'}
stored_codes_str = "STORED_CODES = [\n"
for item in props.items:
if not item.text_ptr: continue
raw_code = item.text_ptr.as_string()
safe_name = item.name
encoded_bytes = base64.b64encode(raw_code.encode('utf-8'))
encoded_str = encoded_bytes.decode('utf-8')
chunks = [encoded_str[i:i+80] for i in range(0, len(encoded_str), 80)]
formatted_b64 = '"\n "'.join(chunks)
stored_codes_str += f' {{\n'
stored_codes_str += f' "name": "{safe_name}",\n'
stored_codes_str += f' "code_b64": (\n "{formatted_b64}"\n )\n'
stored_codes_str += f' }},\n'
stored_codes_str += "]\n"
# 日時指定フォーマットを「YYYY_MMDD_HHMM」に統一
current_time_str = datetime.datetime.now().strftime("%Y_%m%d_%H%M")
# Pythonの識別子(PREFIX)としてアンダーバー入りのものは使用できないため、置換して安全なクラス命名用Prefixを生成
container_prefix = f"CONT_{current_time_str.replace('_', '')}"
target_title = props.container_title.strip()
target_category = props.container_category.strip()
c_link_name = props.custom_link_name.strip()
c_link_url = props.custom_link_url.strip()
template = f"""# ===================================================================
# 1. bl_info
# ===================================================================
bl_info = {{
"name": "{target_title}",
"author": "Generated by Builder",
"version": (1, 0, 0),
"blender": (5, 0, 0),
"location": "View3D > Sidebar > {target_category}",
"description": "複数のアドオンコードを格納・展開するコンテナ (Blender 5以降専用)",
"category": "3D View",
}}
# ===================================================================
# 2. import
# ===================================================================
import bpy
import base64
import webbrowser
from bpy.types import Operator, Panel
# ===================================================================
# 3. 共通定義
# ===================================================================
ADDON_TITLE = "{target_title}"
PREFIX_VAL = "{container_prefix}"
ADDON_CATEGORY = "{target_category}"
PREFIX = PREFIX_VAL.lower()
# ===================================================================
# 4. リンク定義
# ===================================================================
LINK_NAME_1 = "{c_link_name}"
LINK_URL_1 = "{c_link_url}"
LINK_NAME_2 = "{LINK_NAME_2}"
LINK_URL_2 = "{LINK_URL_2}"
LINK_NAME_3 = "{LINK_NAME_3}"
LINK_URL_3 = "{LINK_URL_3}"
LINK_NAME_4 = "{LINK_NAME_4}"
LINK_URL_4 = "{LINK_URL_4}"
LINK_NAME_5 = ""
LINK_URL_5 = ""
# ===================================================================
# 5. 共通クラス
# ===================================================================
class {container_prefix}_OT_OpenUrl(Operator):
bl_idname = f"{{PREFIX}}.open_url"
bl_label = "リンクを開く"
url: bpy.props.StringProperty()
def execute(self, context):
webbrowser.open(self.url)
return {{'FINISHED'}}
# ===================================================================
# 6. アドオン固有機能
# ===================================================================
{stored_codes_str}
class {container_prefix}_OT_OpenCode(Operator):
bl_idname = f"{{PREFIX}}.open_stored_code"
bl_label = "テキストエディタで開く"
code_index: bpy.props.IntProperty()
def execute(self, context):
item = STORED_CODES[self.code_index]
text_name = item["name"] + ".py"
code_text = base64.b64decode(item["code_b64"]).decode('utf-8')
if text_name in bpy.data.texts:
t = bpy.data.texts[text_name]
t.clear()
t.write(code_text)
else:
t = bpy.data.texts.new(text_name)
t.use_fake_user = True
t.write(code_text)
editor_found = False
for win in context.window_manager.windows:
for area in win.screen.areas:
if area.type == 'TEXT_EDITOR':
area.spaces.active.text = t
editor_found = True
break
if editor_found:
break
if not editor_found:
self.report({{'INFO'}}, f"'{{text_name}}' を生成しました。Text Editorを開いてください。")
return {{'FINISHED'}}
class {container_prefix}_OT_RemoveContainer(Operator):
bl_idname = f"{{PREFIX}}.remove_container"
bl_label = "Unregister Container"
def execute(self, context):
bpy.app.timers.register(unregister, first_interval=0.01)
return {{'FINISHED'}}
class {container_prefix}_PT_ContainerPanel(Panel):
bl_label = ADDON_TITLE
bl_idname = f"{container_prefix}_PT_container_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_order = 1
def draw(self, context):
layout = self.layout
box = layout.box()
if not STORED_CODES:
box.label(text="コードがありません")
else:
for i, item in enumerate(STORED_CODES):
row = box.row(align=True)
row.label(text=str(i + 1) + ": " + item["name"], icon='FILE_SCRIPT')
# 右寄せサブレイアウトにより、「展開」ボタンを常に最小サイズで右寄せ維持します
op_row = row.row(align=True)
op_row.alignment = 'RIGHT'
op = op_row.operator(f"{{PREFIX}}.open_stored_code", text="展開", icon='TEXT')
op.code_index = i
class {container_prefix}_PT_ContainerLinksPanel(Panel):
bl_label = "リンク"
bl_idname = f"{container_prefix}_PT_container_links_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_order = 2
def draw(self, context):
layout = self.layout
box = layout.box()
if LINK_NAME_1 and LINK_URL_1:
op1 = box.operator(f"{{PREFIX}}.open_url", text=LINK_NAME_1, icon='URL')
op1.url = LINK_URL_1
if LINK_NAME_2:
op2 = box.operator(f"{{PREFIX}}.open_url", text=LINK_NAME_2, icon='URL')
op2.url = LINK_URL_2
if LINK_NAME_3:
op3 = box.operator(f"{{PREFIX}}.open_url", text=LINK_NAME_3, icon='URL')
op3.url = LINK_URL_3
if LINK_NAME_4:
op4 = box.operator(f"{{PREFIX}}.open_url", text=LINK_NAME_4, icon='URL')
op4.url = LINK_URL_4
class {container_prefix}_PT_ContainerRemovePanel(Panel):
bl_label = "アドオン削除"
bl_idname = f"{container_prefix}_PT_container_remove_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_options = {{'DEFAULT_CLOSED'}}
bl_order = 3
def draw(self, context):
layout = self.layout
box = layout.box()
box.label(text="このコンテナアドオンを消去します", icon='ERROR')
box.operator(f"{{PREFIX}}.remove_container", icon='CANCEL')
# ===================================================================
# 7. register / unregister
# ===================================================================
classes = (
{container_prefix}_OT_OpenUrl,
{container_prefix}_OT_OpenCode,
{container_prefix}_OT_RemoveContainer,
{container_prefix}_PT_ContainerPanel,
{container_prefix}_PT_ContainerLinksPanel,
{container_prefix}_PT_ContainerRemovePanel
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
"""
out_name = f"{target_title}.py".replace(" ", "_").replace(" ", "_")
if out_name in bpy.data.texts:
out_text = bpy.data.texts[out_name]
out_text.clear()
else:
out_text = bpy.data.texts.new(out_name)
out_text.write(template)
for win in context.window_manager.windows:
for area in win.screen.areas:
if area.type == 'TEXT_EDITOR':
area.spaces.active.text = out_text
self.report({'INFO'}, f"コンテナコード '{out_name}' を生成しました")
return {'FINISHED'}
# =========================================================
# UI パネル (UIListを含む)
# =========================================================
class ZIONAD_UL_BuilderList(UIList):
bl_idname = f"{PREFIX_VAL}_UL_builder_list"
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index=0, flt_flag=0):
layout.label(text=f"{index + 1}: {item.name}", icon='FILE_SCRIPT')
class ZIONAD_PT_BuilderPanel(Panel):
bl_label = ADDON_TITLE
bl_idname = f"{PREFIX_VAL}_PT_builder_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_order = 1
def draw(self, context):
layout = self.layout
props = get_props(context)
if not props: return
box = layout.box()
box.label(text="1. 取り込み操作:")
box.operator(ZIONAD_OT_BuilderAddFile.bl_idname, icon='FILEBROWSER')
box.operator(ZIONAD_OT_BuilderAddClipboard.bl_idname, icon='PASTEDOWN')
box.operator(ZIONAD_OT_BuilderImportFromContainerText.bl_idname, icon='TEXT')
layout.separator()
layout.label(text="2. コード一覧(ソート・順序変更):")
row = layout.row()
row.template_list(ZIONAD_UL_BuilderList.bl_idname, "", props, "items", props, "active_index", rows=5)
col = row.column(align=True)
col.operator(ZIONAD_OT_BuilderRemove.bl_idname, text="", icon='REMOVE')
col.separator()
op_up = col.operator(ZIONAD_OT_BuilderMoveItem.bl_idname, text="", icon='TRIA_UP')
op_up.direction = 'UP'
op_down = col.operator(ZIONAD_OT_BuilderMoveItem.bl_idname, text="", icon='TRIA_DOWN')
op_down.direction = 'DOWN'
layout.operator(ZIONAD_OT_BuilderSortItems.bl_idname, icon='SORTALPHA')
# 3. コンテナ設定
layout.separator()
layout.label(text="3. コンテナ設定:")
box_cfg = layout.box()
box_cfg.prop(props, "container_title")
box_cfg.prop(props, "container_category")
box_cfg.separator()
box_cfg.label(text="追加カスタムリンク (任意項目):", icon='URL')
box_cfg.prop(props, "custom_link_name", text="リンク1 タイトル")
box_cfg.prop(props, "custom_link_url", text="リンク1 URL")
# 4. アドオンの作成
layout.separator()
layout.label(text="4. アドオンの作成:")
col = layout.column(align=True)
col.scale_y = 1.5
col.operator(ZIONAD_OT_BuilderGenerate.bl_idname, icon='FILE_TICK')
class ZIONAD_PT_BuilderLinksPanel(Panel):
bl_label = "リンク"
bl_idname = f"{PREFIX_VAL}_PT_builder_links_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_order = 2
def draw(self, context):
layout = self.layout
box = layout.box()
op2 = box.operator(ZIONAD_OT_OpenUrl.bl_idname, text=LINK_NAME_2, icon='URL')
op2.url = LINK_URL_2
op3 = box.operator(ZIONAD_OT_OpenUrl.bl_idname, text=LINK_NAME_3, icon='URL')
op3.url = LINK_URL_3
op4 = box.operator(ZIONAD_OT_OpenUrl.bl_idname, text=LINK_NAME_4, icon='URL')
op4.url = LINK_URL_4
class ZIONAD_PT_BuilderRemovePanel(Panel):
bl_label = "アドオン削除"
bl_idname = f"{PREFIX_VAL}_PT_builder_remove_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = ADDON_CATEGORY
bl_options = {'DEFAULT_CLOSED'}
bl_order = 3
def draw(self, context):
layout = self.layout
box = layout.box()
box.label(text="このビルダーアドオンを消去します", icon='ERROR')
box.operator(ZIONAD_OT_RemoveBuilder.bl_idname, icon='CANCEL')
# =========================================================
# 登録・解除
# =========================================================
classes = (
ZIONAD_BuilderItem,
ZIONAD_BuilderProps,
ZIONAD_OT_BuilderAddClipboard,
ZIONAD_OT_BuilderAddFile,
ZIONAD_OT_BuilderImportFromContainerText,
ZIONAD_OT_BuilderRemove,
ZIONAD_OT_BuilderMoveItem,
ZIONAD_OT_BuilderSortItems,
ZIONAD_OT_OpenUrl,
ZIONAD_OT_RemoveBuilder,
ZIONAD_OT_BuilderGenerate,
ZIONAD_UL_BuilderList,
ZIONAD_PT_BuilderPanel,
ZIONAD_PT_BuilderLinksPanel,
ZIONAD_PT_BuilderRemovePanel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
prop_name = f"{PREFIX_VAL.lower()}_props"
setattr(bpy.types.Scene, prop_name, PointerProperty(type=ZIONAD_BuilderProps))
def unregister():
prop_name = f"{PREFIX_VAL.lower()}_props"
if hasattr(bpy.types.Scene, prop_name):
delattr(bpy.types.Scene, prop_name)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()