位置関係 説明 from 20260715

平面2つの 作成過程

7つ .py ファイル コンテナ

間違って 他のを貼ったので 時間があれば修正

bl_info = {
    "name": "Observation Planes Extended",
    "author": "zionad2022",
    "version": (1, 9, 1),
    "blender": (5, 0, 0),
    "location": "View3D > Sidebar(N) > Observation Plane",
    "description": "2人の観測者による時間の流れ(過去方向)の不一致を可視化するアドオン",
    "category": "3D View",
}

# Code Created : 2026_0716_0730

import bpy
import bmesh
import math
import mathutils

# =========================================================
# 4. 共通定義
# =========================================================
ADDON_TITLE = "Observation Planes"
PREFIX_VAL = "ObsPlane"
ADDON_CATEGORY = ADDON_TITLE
PREFIX = PREFIX_VAL.lower()

# =========================================================
# 5. リンク定義
# =========================================================
LINK_NAME_1 = "アドオン専用ページ"
LINK_URL_1 = "URL"
LINK_NAME_2 = "posfie"
LINK_URL_2 = "<https://posfie.com/@timekagura/t/zionad2022?sort=0>"
LINK_NAME_3 = "zionadchat"
LINK_URL_3 = "<https://x.com/zionadchat>"
LINK_NAME_4 = "blender コンテナ 20260713"
LINK_URL_4 = "<https://note.com/zionadmillion/n/n9a47d88fe7b4>"
LINK_NAME_5 = ""
LINK_URL_5 = ""

# =========================================================
# 6. 初期設定 (Copied from UI)
# =========================================================
GREEN_X_MIN_DEFAULT = -10.000
GREEN_X_MAX_DEFAULT = 10.000
TRIANGLE_EXTENSION_DEFAULT = 2.000

BLUE_EYE_X_DEFAULT = 0.000
BLUE_EYE_Y_DEFAULT = -10.000
BLUE_EYE_Z_DEFAULT = -10.000
RED_EYE_X_DEFAULT = 0.000
RED_EYE_Y_DEFAULT = 10.000
RED_EYE_Z_DEFAULT = -10.000

PAST_ARROW_LENGTH_DEFAULT = 5.000

COLOR_GREEN_EDGE = (0.000, 1.000, 0.500, 1.000)
COLOR_TEXT_DEFAULT = (1.000, 1.000, 1.000, 1.000)
COLOR_BLUE_FRONT_DEFAULT = (0.016, 0.083, 0.448, 0.327)
COLOR_BLUE_BACK_DEFAULT = (0.129, 0.467, 0.546, 1.000)
COLOR_RED_FRONT_DEFAULT = (1.000, 0.200, 0.200, 0.500)
COLOR_RED_BACK_DEFAULT = (0.921, 0.045, 1.000, 0.152)

USE_EMISSION_DEFAULT = False
EMISSION_STRENGTH_FRONT_DEFAULT = 10.000
EMISSION_STRENGTH_BACK_DEFAULT = 1.000
ENABLE_BLOOM_DEFAULT = False
BLOOM_THRESHOLD_DEFAULT = 0.000
BLOOM_MIX_DEFAULT = 0.000
WORLD_BRIGHTNESS_DEFAULT = 0.100

TEXT_SIZE_DEFAULT = 1.000
TEXT_OFFSET_X_DEFAULT = 0.000
TEXT_OFFSET_Y_DEFAULT = 0.000
TEXT_OFFSET_Z_DEFAULT = 0.000
TEXT_ROT_X_DEFAULT = 90.000
TEXT_ROT_Y_DEFAULT = 0.000
TEXT_ROT_Z_DEFAULT = 0.000

# =========================================================
# 7. 共通クラス (Utility Operators)
# =========================================================
class OBSPLANE_OT_OpenUrl(bpy.types.Operator):
    bl_idname = f"{PREFIX}.open_url"
    bl_label = "Open URL"
    url: bpy.props.StringProperty()
    def execute(self, context):
        bpy.ops.wm.url_open(url=self.url)
        return {'FINISHED'}

class OBSPLANE_OT_Uninstall(bpy.types.Operator):
    bl_idname = f"{PREFIX}.uninstall"
    bl_label = "アドオンを完全削除 (Unregister)"
    def execute(self, context):
        import sys
        mod_name = __name__
        if mod_name in sys.modules:
            unregister()
            self.report({'INFO'}, f"【{ADDON_TITLE}】をUnregisterしました。")
        return {'FINISHED'}

class OBSPLANE_OT_SwitchRenderEngine(bpy.types.Operator):
    bl_idname = f"{PREFIX}.switch_render_engine"
    bl_label = "レンダーエンジン切り替え"
    engine: bpy.props.StringProperty()
    def execute(self, context):
        try:
            context.scene.render.engine = self.engine
        except TypeError:
            if self.engine == 'BLENDER_EEVEE_NEXT':
                context.scene.render.engine = 'BLENDER_EEVEE'
        return {'FINISHED'}

class OBSPLANE_OT_CopyDefaults(bpy.types.Operator):
    bl_idname = f"{PREFIX}.copy_defaults"
    bl_label = "設定値をコードとしてコピー"
    def execute(self, context):
        props = getattr(context.scene, f"{PREFIX}_props")
        def c_str(c): return f"({c[0]:.3f}, {c[1]:.3f}, {c[2]:.3f}, {c[3]:.3f})"
        
        code = f"""# =========================================================
# 6. 初期設定 (Copied from UI)
# =========================================================
GREEN_X_MIN_DEFAULT = {props.green_x_min:.3f}
GREEN_X_MAX_DEFAULT = {props.green_x_max:.3f}
TRIANGLE_EXTENSION_DEFAULT = {props.triangle_extension:.3f}

BLUE_EYE_X_DEFAULT = {props.blue_eye_x:.3f}
BLUE_EYE_Y_DEFAULT = {props.blue_eye_y:.3f}
BLUE_EYE_Z_DEFAULT = {props.blue_eye_z:.3f}
RED_EYE_X_DEFAULT = {props.red_eye_x:.3f}
RED_EYE_Y_DEFAULT = {props.red_eye_y:.3f}
RED_EYE_Z_DEFAULT = {props.red_eye_z:.3f}

PAST_ARROW_LENGTH_DEFAULT = {props.past_arrow_length:.3f}

COLOR_GREEN_EDGE = {c_str(props.color_green)}
COLOR_TEXT_DEFAULT = {c_str(props.color_text)}
COLOR_BLUE_FRONT_DEFAULT = {c_str(props.color_blue_front)}
COLOR_BLUE_BACK_DEFAULT = {c_str(props.color_blue_back)}
COLOR_RED_FRONT_DEFAULT = {c_str(props.color_red_front)}
COLOR_RED_BACK_DEFAULT = {c_str(props.color_red_back)}

USE_EMISSION_DEFAULT = {props.use_emission}
EMISSION_STRENGTH_FRONT_DEFAULT = {props.emission_strength_front:.3f}
EMISSION_STRENGTH_BACK_DEFAULT = {props.emission_strength_back:.3f}
ENABLE_BLOOM_DEFAULT = {props.enable_bloom}
BLOOM_THRESHOLD_DEFAULT = {props.bloom_threshold:.3f}
BLOOM_MIX_DEFAULT = {props.bloom_mix:.3f}
WORLD_BRIGHTNESS_DEFAULT = {props.world_brightness:.3f}

TEXT_SIZE_DEFAULT = {props.text_size:.3f}
TEXT_OFFSET_X_DEFAULT = {props.text_offset_x:.3f}
TEXT_OFFSET_Y_DEFAULT = {props.text_offset_y:.3f}
TEXT_OFFSET_Z_DEFAULT = {props.text_offset_z:.3f}
TEXT_ROT_X_DEFAULT = {props.text_rot_x:.3f}
TEXT_ROT_Y_DEFAULT = {props.text_rot_y:.3f}
TEXT_ROT_Z_DEFAULT = {props.text_rot_z:.3f}
"""
        context.window_manager.clipboard = code
        self.report({'INFO'}, "クリップボードに初期設定コードをコピーしました")
        return {'FINISHED'}

class OBSPLANE_OT_ResetDefaults(bpy.types.Operator):
    bl_idname = f"{PREFIX}.reset_defaults"
    bl_label = "初期値にリセット"
    def execute(self, context):
        props = getattr(context.scene, f"{PREFIX}_props")
        props.green_x_min = GREEN_X_MIN_DEFAULT
        props.green_x_max = GREEN_X_MAX_DEFAULT
        props.triangle_extension = TRIANGLE_EXTENSION_DEFAULT
        props.blue_eye_x = BLUE_EYE_X_DEFAULT
        props.blue_eye_y = BLUE_EYE_Y_DEFAULT
        props.blue_eye_z = BLUE_EYE_Z_DEFAULT
        props.red_eye_x = RED_EYE_X_DEFAULT
        props.red_eye_y = RED_EYE_Y_DEFAULT
        props.red_eye_z = RED_EYE_Z_DEFAULT
        props.past_arrow_length = PAST_ARROW_LENGTH_DEFAULT
        
        props.color_green = COLOR_GREEN_EDGE
        props.color_text = COLOR_TEXT_DEFAULT
        props.color_blue_front = COLOR_BLUE_FRONT_DEFAULT
        props.color_blue_back = COLOR_BLUE_BACK_DEFAULT
        props.color_red_front = COLOR_RED_FRONT_DEFAULT
        props.color_red_back = COLOR_RED_BACK_DEFAULT
        
        props.use_emission = USE_EMISSION_DEFAULT
        props.emission_strength_front = EMISSION_STRENGTH_FRONT_DEFAULT
        props.emission_strength_back = EMISSION_STRENGTH_BACK_DEFAULT
        props.enable_bloom = ENABLE_BLOOM_DEFAULT
        props.bloom_threshold = BLOOM_THRESHOLD_DEFAULT
        props.bloom_mix = BLOOM_MIX_DEFAULT
        props.world_brightness = WORLD_BRIGHTNESS_DEFAULT
        
        props.text_size = TEXT_SIZE_DEFAULT
        props.text_offset_x = TEXT_OFFSET_X_DEFAULT
        props.text_offset_y = TEXT_OFFSET_Y_DEFAULT
        props.text_offset_z = TEXT_OFFSET_Z_DEFAULT
        props.text_rot_x = TEXT_ROT_X_DEFAULT
        props.text_rot_y = TEXT_ROT_Y_DEFAULT
        props.text_rot_z = TEXT_ROT_Z_DEFAULT
        self.report({'INFO'}, "設定を初期値にリセットしました")
        return {'FINISHED'}

# =========================================================
# 8. アドオン固有機能 (コンポジター連携コールバック等)
# =========================================================
def setup_compositor_bloom(scene):
    scene.use_nodes = True
    tree = scene.node_tree
    if not tree: return
    rl_node = next((n for n in tree.nodes if n.type == 'R_LAYERS'), None)
    comp_node = next((n for n in tree.nodes if n.type == 'COMPOSITE'), None)
    glare_node = next((n for n in tree.nodes if n.type == 'GLARE' and getattr(n, 'glare_type', '') == 'BLOOM'), None)
    
    if not rl_node: rl_node = tree.nodes.new('CompositorNodeRLayers'); rl_node.location = (-300, 0)
    if not comp_node: comp_node = tree.nodes.new('CompositorNodeComposite'); comp_node.location = (300, 0)
    if not glare_node:
        glare_node = tree.nodes.new('CompositorNodeGlare')
        glare_node.location = (0, 0)
        glare_node.glare_type = 'BLOOM'
        
    for link in list(tree.links):
        if link.from_node == rl_node and link.to_node == comp_node:
            tree.links.remove(link)
            
    if rl_node.outputs and glare_node.inputs:
        if not any(l.from_node == rl_node and l.to_node == glare_node for l in tree.links):
            tree.links.new(rl_node.outputs[0], glare_node.inputs[0])
    if glare_node.outputs and comp_node.inputs:
        if not any(l.from_node == glare_node and l.to_node == comp_node for l in tree.links):
            tree.links.new(glare_node.outputs[0], comp_node.inputs[0])

def update_world_brightness(self, context):
    if getattr(context, "scene", None) is None:
        return
    world = context.scene.world
    if not world: return
    if not world.use_nodes: world.use_nodes = True
    tree = world.node_tree
    if tree:
        for node in tree.nodes:
            if node.type == 'BACKGROUND':
                try: node.inputs['Strength'].default_value = self.world_brightness
                except KeyError: node.inputs[1].default_value = self.world_brightness

def update_bloom_settings(self, context):
    if getattr(context, "scene", None) is None:
        return
    scene = context.scene
    try:
        if self.enable_bloom:
            scene.use_nodes = True
            tree = getattr(scene, 'node_tree', None)
            if tree:
                glare_node = next((n for n in tree.nodes if n.type == 'GLARE' and getattr(n, 'glare_type', '') == 'BLOOM'), None)
                if not glare_node:
                    setup_compositor_bloom(scene)
                    glare_node = next((n for n in tree.nodes if n.type == 'GLARE' and getattr(n, 'glare_type', '') == 'BLOOM'), None)
                if glare_node:
                    glare_node.mute = False
                    glare_node.mix = self.bloom_mix
                    glare_node.threshold = self.bloom_threshold
            update_world_brightness(self, context)
            
            if hasattr(context, "screen") and context.screen:
                for area in context.screen.areas:
                    if area.type == 'VIEW_3D':
                        for space in area.spaces:
                            if space.type == 'VIEW_3D':
                                space.shading.type = 'RENDERED'
                                space.shading.use_compositor = 'ALWAYS'
        else:
            tree = getattr(scene, 'node_tree', None)
            if tree:
                for node in tree.nodes:
                    if node.type == 'GLARE' and getattr(node, 'glare_type', '') == 'BLOOM':
                        node.mute = True
    except Exception as e:
        print(f"Bloom更新エラー: {e}")

# =========================================================
# 8. アドオン固有機能 (ジオメトリとマテリアル)
# =========================================================
def get_or_create_collection():
    coll_name = f"{PREFIX_VAL}_Collection"
    coll = bpy.data.collections.get(coll_name)
    if not coll:
        coll = bpy.data.collections.new(coll_name)
        bpy.context.scene.collection.children.link(coll)
    return coll

def get_or_create_material_simple(name, color):
    mat_name = f"{PREFIX_VAL}_{name}"
    mat = bpy.data.materials.get(mat_name)
    if not mat:
        mat = bpy.data.materials.new(name=mat_name)
        mat.use_nodes = True
    
    try:
        mat.surface_render_method = 'BLENDED'
        mat.use_transparency_overlap = True
    except AttributeError:
        mat.blend_method = 'BLEND'
        
    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = color
        bsdf.inputs["Alpha"].default_value = color[3]
    return mat

def get_or_create_material_double_sided(name, c_front, c_back, use_em, str_front, str_back):
    mat_name = f"{PREFIX_VAL}_{name}"
    mat = bpy.data.materials.get(mat_name)
    if not mat:
        mat = bpy.data.materials.new(name=mat_name)
        mat.use_nodes = True
        mat.show_transparent_back = False
        
    try:
        mat.surface_render_method = 'BLENDED'
        mat.use_transparency_overlap = True
    except AttributeError:
        mat.blend_method = 'BLEND'
        
    mat.node_tree.nodes.clear()
    nodes = mat.node_tree.nodes
    links = mat.node_tree.links
    
    n_out = nodes.new('ShaderNodeOutputMaterial')
    n_mix = nodes.new('ShaderNodeMixShader')
    n_geo = nodes.new('ShaderNodeNewGeometry')
    
    n_front = nodes.new('ShaderNodeBsdfPrincipled')
    n_front.inputs['Base Color'].default_value = c_front
    n_front.inputs['Alpha'].default_value = c_front[3] 
    if use_em:
        n_front.inputs['Emission Color'].default_value = c_front
        n_front.inputs['Emission Strength'].default_value = str_front
    else:
        n_front.inputs['Emission Strength'].default_value = 0.0

    n_back = nodes.new('ShaderNodeBsdfPrincipled')
    n_back.inputs['Base Color'].default_value = c_back
    n_back.inputs['Alpha'].default_value = c_back[3]   
    if use_em:
        n_back.inputs['Emission Color'].default_value = c_back
        n_back.inputs['Emission Strength'].default_value = str_back
    else:
        n_back.inputs['Emission Strength'].default_value = 0.0
    
    links.new(n_geo.outputs['Backfacing'], n_mix.inputs[0]) 
    links.new(n_front.outputs[0], n_mix.inputs[1])          
    links.new(n_back.outputs[0], n_mix.inputs[2])           
    links.new(n_mix.outputs[0], n_out.inputs['Surface'])
    
    return mat

def create_or_update_mesh(obj_name, vertices, faces, mat):
    coll = get_or_create_collection()
    full_name = f"{PREFIX_VAL}_{obj_name}"
    
    obj = bpy.data.objects.get(full_name)
    if not obj:
        mesh = bpy.data.meshes.new(name=full_name)
        obj = bpy.data.objects.new(full_name, mesh)
        coll.objects.link(obj)
    else:
        mesh = obj.data
        mesh.clear_geometry()

    bm = bmesh.new()
    for v in vertices: bm.verts.new(v)
    bm.verts.ensure_lookup_table()
    
    if faces:
        for f in faces: bm.faces.new([bm.verts[i] for i in f])
    elif len(vertices) == 2:
        bm.edges.new((bm.verts[0], bm.verts[1]))
    
    bm.to_mesh(mesh)
    bm.free()
    
    if len(obj.data.materials) == 0: obj.data.materials.append(mat)
    else: obj.data.materials[0] = mat
    return obj

def create_or_update_text(obj_name, text_body, loc, rot, mat, size):
    coll = get_or_create_collection()
    full_name = f"{PREFIX_VAL}_Text_{obj_name}"
    
    obj = bpy.data.objects.get(full_name)
    if not obj:
        curve = bpy.data.curves.new(type="FONT", name=full_name)
        obj = bpy.data.objects.new(full_name, curve)
        coll.objects.link(obj)
    
    obj.data.body = text_body
    obj.location = loc
    obj.data.size = size
    obj.data.align_x = 'CENTER'
    obj.data.align_y = 'CENTER'
    
    if len(obj.data.materials) == 0: obj.data.materials.append(mat)
    else: obj.data.materials[0] = mat
    obj.rotation_euler = rot
    return obj

def update_geometry(self, context):
    if getattr(context, "scene", None) is None:
        return
        
    props = getattr(context.scene, f"{PREFIX}_props")
    if not props.is_active: return

    x_min = props.green_x_min
    x_max = props.green_x_max
    ext = props.triangle_extension
    
    v_green_a = mathutils.Vector((x_min, 0, 0))
    v_green_b = mathutils.Vector((x_max, 0, 0))
    v_blue_eye = mathutils.Vector((props.blue_eye_x, props.blue_eye_y, props.blue_eye_z))
    v_red_eye = mathutils.Vector((props.red_eye_x, props.red_eye_y, props.red_eye_z))

    v_blue_ext_a = v_blue_eye + (v_green_a - v_blue_eye) * ext
    v_blue_ext_b = v_blue_eye + (v_green_b - v_blue_eye) * ext
    v_red_ext_a = v_red_eye + (v_green_a - v_red_eye) * ext
    v_red_ext_b = v_red_eye + (v_green_b - v_red_eye) * ext

    mat_green = get_or_create_material_simple("Mat_Green", props.color_green)
    mat_text = get_or_create_material_simple("Mat_Text", props.color_text)
    
    mat_blue = get_or_create_material_double_sided(
        "Mat_Blue", props.color_blue_front, props.color_blue_back, 
        props.use_emission, props.emission_strength_front, props.emission_strength_back
    )
    mat_red = get_or_create_material_double_sided(
        "Mat_Red", props.color_red_front, props.color_red_back, 
        props.use_emission, props.emission_strength_front, props.emission_strength_back
    )

    create_or_update_mesh("GreenEdge", [v_green_a, v_green_b], [], mat_green)
    obj_blue = create_or_update_mesh("BlueTriangle", [v_blue_eye, v_blue_ext_a, v_blue_ext_b], [[0, 1, 2]], mat_blue)
    obj_red = create_or_update_mesh("RedTriangle", [v_red_eye, v_red_ext_a, v_red_ext_b], [[0, 1, 2]], mat_red)

    if props.show_past_arrow:
        x_axis = mathutils.Vector((1, 0, 0))
        blue_normal = mathutils.geometry.normal([v_blue_eye, v_blue_ext_b, v_blue_ext_a])
        blue_past_dir = x_axis.cross(blue_normal)
        if blue_past_dir.y < 0: blue_past_dir = -blue_past_dir
        
        red_normal = mathutils.geometry.normal([v_red_eye, v_red_ext_b, v_red_ext_a])
        red_past_dir = x_axis.cross(red_normal)
        if red_past_dir.y > 0: red_past_dir = -red_past_dir
        
        create_or_update_mesh("BluePastArrow", [(0,0,0), blue_past_dir * props.past_arrow_length], [], mat_blue)
        create_or_update_mesh("RedPastArrow", [(0,0,0), red_past_dir * props.past_arrow_length], [], mat_red)
    else:
        create_or_update_mesh("BluePastArrow", [], [], mat_blue)
        create_or_update_mesh("RedPastArrow", [], [], mat_red)

    offset = mathutils.Vector((props.text_offset_x, props.text_offset_y, props.text_offset_z))
    rot = (math.radians(props.text_rot_x), math.radians(props.text_rot_y), math.radians(props.text_rot_z))

    if props.show_labels:
        loc_blue = v_blue_eye + mathutils.Vector((0, -2, 0)) + offset
        create_or_update_text("Label_Blue", "「俺」の観測平面 (青)\nY > 0 が過去", loc_blue, rot, mat_text, props.text_size)
        loc_red = v_red_eye + mathutils.Vector((0, 2, 0)) + offset
        create_or_update_text("Label_Red", "「貴殿」の観測平面 (赤)\nY < 0 が過去", loc_red, rot, mat_text, props.text_size)
        loc_green = v_green_b + mathutils.Vector((1.5, 0, 0)) + offset
        create_or_update_text("Label_Green", "共通事象 (現在)", loc_green, rot, mat_text, props.text_size)
    else:
        create_or_update_text("Label_Blue", "", (0,0,0), (0,0,0), mat_text, props.text_size)
        create_or_update_text("Label_Red", "", (0,0,0), (0,0,0), mat_text, props.text_size)
        create_or_update_text("Label_Green", "", (0,0,0), (0,0,0), mat_text, props.text_size)

# =========================================================
# 8. アドオン固有機能 (Properties & Operator)
# =========================================================
class OBSPLANE_Properties(bpy.types.PropertyGroup):
    is_active: bpy.props.BoolProperty(default=False)
    
    green_x_min: bpy.props.FloatProperty(name="Min X", default=GREEN_X_MIN_DEFAULT, update=update_geometry)
    green_x_max: bpy.props.FloatProperty(name="Max X", default=GREEN_X_MAX_DEFAULT, update=update_geometry)
    triangle_extension: bpy.props.FloatProperty(name="延長率", default=TRIANGLE_EXTENSION_DEFAULT, min=1.0, max=10.0, update=update_geometry)
    
    blue_eye_x: bpy.props.FloatProperty(name="Blue X", default=BLUE_EYE_X_DEFAULT, update=update_geometry)
    blue_eye_y: bpy.props.FloatProperty(name="Blue Y", default=BLUE_EYE_Y_DEFAULT, update=update_geometry)
    blue_eye_z: bpy.props.FloatProperty(name="Blue Z", default=BLUE_EYE_Z_DEFAULT, update=update_geometry)
    
    red_eye_x: bpy.props.FloatProperty(name="Red X", default=RED_EYE_X_DEFAULT, update=update_geometry)
    red_eye_y: bpy.props.FloatProperty(name="Red Y", default=RED_EYE_Y_DEFAULT, update=update_geometry)
    red_eye_z: bpy.props.FloatProperty(name="Red Z", default=RED_EYE_Z_DEFAULT, update=update_geometry)
    
    show_past_arrow: bpy.props.BoolProperty(name="Show Past Arrow", default=True, update=update_geometry)
    past_arrow_length: bpy.props.FloatProperty(name="Past Arrow Length", default=PAST_ARROW_LENGTH_DEFAULT, update=update_geometry)
    show_labels: bpy.props.BoolProperty(name="Show Labels", default=True, update=update_geometry)
    
    # カラー(min=0.0, max=1.0 を追加し、範囲を制限)
    color_green: bpy.props.FloatVectorProperty(name="共通事象", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_GREEN_EDGE, update=update_geometry)
    color_text: bpy.props.FloatVectorProperty(name="テキスト", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_TEXT_DEFAULT, update=update_geometry)
    color_blue_front: bpy.props.FloatVectorProperty(name="青平面 (表面)", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_BLUE_FRONT_DEFAULT, update=update_geometry)
    color_blue_back: bpy.props.FloatVectorProperty(name="青平面 (裏面)", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_BLUE_BACK_DEFAULT, update=update_geometry)
    color_red_front: bpy.props.FloatVectorProperty(name="赤平面 (表面)", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_RED_FRONT_DEFAULT, update=update_geometry)
    color_red_back: bpy.props.FloatVectorProperty(name="赤平面 (裏面)", subtype='COLOR', size=4, min=0.0, max=1.0, default=COLOR_RED_BACK_DEFAULT, update=update_geometry)
    
    # 発光・Bloom
    use_emission: bpy.props.BoolProperty(name="発光を有効にする", default=USE_EMISSION_DEFAULT, update=update_geometry)
    emission_strength_front: bpy.props.FloatProperty(name="表面 発光強度", min=0.0, max=1000.0, default=EMISSION_STRENGTH_FRONT_DEFAULT, update=update_geometry)
    emission_strength_back: bpy.props.FloatProperty(name="裏面 発光強度", min=0.0, max=1000.0, default=EMISSION_STRENGTH_BACK_DEFAULT, update=update_geometry)
    
    enable_bloom: bpy.props.BoolProperty(name="Bloom を有効にする", default=ENABLE_BLOOM_DEFAULT, update=update_bloom_settings)
    bloom_threshold: bpy.props.FloatProperty(name="発光しきい値", min=0.0, max=10.0, default=BLOOM_THRESHOLD_DEFAULT, update=update_bloom_settings)
    bloom_mix: bpy.props.FloatProperty(name="広がりの強さ(Mix)", min=-1.0, max=1.0, default=BLOOM_MIX_DEFAULT, subtype='FACTOR', update=update_bloom_settings)
    world_brightness: bpy.props.FloatProperty(name="背景の明るさ", min=0.0, max=10.0, default=WORLD_BRIGHTNESS_DEFAULT, subtype='FACTOR', update=update_world_brightness)

    # テキスト
    text_size: bpy.props.FloatProperty(name="文字サイズ", default=TEXT_SIZE_DEFAULT, min=0.1, max=10.0, update=update_geometry)
    text_offset_x: bpy.props.FloatProperty(name="Offset X", default=TEXT_OFFSET_X_DEFAULT, update=update_geometry)
    text_offset_y: bpy.props.FloatProperty(name="Offset Y", default=TEXT_OFFSET_Y_DEFAULT, update=update_geometry)
    text_offset_z: bpy.props.FloatProperty(name="Offset Z", default=TEXT_OFFSET_Z_DEFAULT, update=update_geometry)
    text_rot_x: bpy.props.FloatProperty(name="Rotation X (度)", default=TEXT_ROT_X_DEFAULT, update=update_geometry)
    text_rot_y: bpy.props.FloatProperty(name="Rotation Y (度)", default=TEXT_ROT_Y_DEFAULT, update=update_geometry)
    text_rot_z: bpy.props.FloatProperty(name="Rotation Z (度)", default=TEXT_ROT_Z_DEFAULT, update=update_geometry)

class OBSPLANE_OT_Generate(bpy.types.Operator):
    bl_idname = f"{PREFIX}.generate"
    bl_label = "観測平面を生成"
    def execute(self, context):
        context.scene.obsplane_props.is_active = True
        update_geometry(self, context)
        return {'FINISHED'}

class OBSPLANE_OT_Detach(bpy.types.Operator):
    bl_idname = f"{PREFIX}.detach"
    bl_label = "アドオンから切り離し"
    def execute(self, context):
        context.scene.obsplane_props.is_active = False
        coll = bpy.data.collections.get(f"{PREFIX_VAL}_Collection")
        if coll:
            coll.name = f"{PREFIX_VAL}_Detached"
            for obj in coll.objects: obj.name = obj.name.replace(f"{PREFIX_VAL}_", "Detached_")
        return {'FINISHED'}

# =========================================================
# 9. パネル構成
# =========================================================
class OBSPLANE_PT_MainPanel(bpy.types.Panel):
    bl_label = ADDON_TITLE
    bl_idname = f"{PREFIX.upper()}_PT_MainPanel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = ADDON_CATEGORY

    def draw(self, context):
        layout = self.layout
        props = getattr(context.scene, f"{PREFIX}_props")
        
        row = layout.row(align=True)
        is_eevee = context.scene.render.engine in {'BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'}
        row.operator(f"{PREFIX}.switch_render_engine", text="EEVEE", depress=is_eevee).engine = 'BLENDER_EEVEE_NEXT'
        row.operator(f"{PREFIX}.switch_render_engine", text="Cycles", depress=not is_eevee).engine = 'CYCLES'
        
        layout.separator()
        row = layout.row(align=True)
        row.operator(f"{PREFIX}.copy_defaults", icon='COPYDOWN')
        row.operator(f"{PREFIX}.reset_defaults", icon='FILE_REFRESH')
        
        layout.operator(f"{PREFIX}.generate", icon='PLAY')
        
        box = layout.box()
        box.label(text="【共通事象 (X軸)】")
        row = box.row(align=True)
        row.prop(props, "green_x_min")
        row.prop(props, "green_x_max")
        col = box.column(align=True)
        col.prop(props, "color_green")
        col.prop(props, "color_green", index=3, text="透明度", slider=True)
        
        box = layout.box()
        box.label(text="【青平面 (俺)】", icon='COLOR')
        col = box.column(align=True)
        col.prop(props, "color_blue_front")
        col.prop(props, "color_blue_front", index=3, text="透明度 (表面)", slider=True)
        col.separator()
        col.prop(props, "color_blue_back")
        col.prop(props, "color_blue_back", index=3, text="透明度 (裏面)", slider=True)
        
        box = layout.box()
        box.label(text="【赤平面 (貴殿)】", icon='COLOR')
        col = box.column(align=True)
        col.prop(props, "color_red_front")
        col.prop(props, "color_red_front", index=3, text="透明度 (表面)", slider=True)
        col.separator()
        col.prop(props, "color_red_back")
        col.prop(props, "color_red_back", index=3, text="透明度 (裏面)", slider=True)
        
        box2 = layout.box()
        box2.prop(props, "use_emission", toggle=True, icon='LIGHT')
        if props.use_emission:
            col2 = box2.column(align=True)
            col2.prop(props, "emission_strength_front")
            col2.prop(props, "emission_strength_back")
            
            box_bloom = layout.box()
            box_bloom.label(text="【 Bloom設定 】", icon='SOLO_ON')
            box_bloom.prop(props, "enable_bloom", toggle=True, icon='NODE_COMPOSITING')
            if props.enable_bloom:
                col_b = box_bloom.column(align=True)
                col_b.prop(props, "bloom_mix", slider=True)
                col_b.prop(props, "bloom_threshold", slider=True)
                col_b.prop(props, "world_brightness", slider=True)
        
        box = layout.box()
        box.label(text="【テキスト設定】")
        col = box.column(align=True)
        col.prop(props, "color_text")
        col.prop(props, "color_text", index=3, text="透明度", slider=True)
        box.prop(props, "text_size")
        row = box.row(align=True)
        row.prop(props, "text_offset_x", text="X")
        row.prop(props, "text_offset_y", text="Y")
        row.prop(props, "text_offset_z", text="Z")
        row = box.row(align=True)
        row.prop(props, "text_rot_x", text="RotX")
        row.prop(props, "text_rot_y", text="RotY")
        row.prop(props, "text_rot_z", text="RotZ")
        
        box = layout.box()
        box.label(text="【全体調整・表示切替】")
        box.prop(props, "triangle_extension")
        box.prop(props, "show_past_arrow")
        box.prop(props, "show_labels")
        
        box = layout.box()
        box.label(text="【眼の座標】")
        col = box.column(align=True)
        col.prop(props, "blue_eye_y")
        col.prop(props, "blue_eye_z")
        col.separator()
        col.prop(props, "red_eye_y")
        col.prop(props, "red_eye_z")
        
        layout.separator()
        layout.operator(f"{PREFIX}.detach", icon='UNLINKED')

class OBSPLANE_PT_LinksPanel(bpy.types.Panel):
    bl_label = "Links"
    bl_idname = f"{PREFIX.upper()}_PT_LinksPanel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = ADDON_CATEGORY
    bl_parent_id = f"{PREFIX.upper()}_PT_MainPanel"
    bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context):
        for name, url in [(LINK_NAME_1, LINK_URL_1), (LINK_NAME_2, LINK_URL_2), (LINK_NAME_3, LINK_URL_3), (LINK_NAME_4, LINK_URL_4), (LINK_NAME_5, LINK_URL_5)]:
            if name and url: self.layout.operator(f"{PREFIX}.open_url", text=name, icon='URL').url = url

class OBSPLANE_PT_RemovePanel(bpy.types.Panel):
    bl_label = "Addon Remove"
    bl_idname = f"{PREFIX.upper()}_PT_RemovePanel"
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = ADDON_CATEGORY
    bl_parent_id = f"{PREFIX.upper()}_PT_MainPanel"
    bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context):
        self.layout.label(text="Unregisterを呼び出して解除します", icon='ERROR')
        self.layout.operator(f"{PREFIX}.uninstall", icon='TRASH')

# =========================================================
# 10. register() / 11. unregister()
# =========================================================
classes = (
    OBSPLANE_OT_OpenUrl, OBSPLANE_OT_Uninstall, OBSPLANE_OT_SwitchRenderEngine,
    OBSPLANE_OT_CopyDefaults, OBSPLANE_OT_ResetDefaults, OBSPLANE_Properties,
    OBSPLANE_OT_Generate, OBSPLANE_OT_Detach, OBSPLANE_PT_MainPanel,
    OBSPLANE_PT_LinksPanel, OBSPLANE_PT_RemovePanel
)

def register():
    for cls in classes: bpy.utils.register_class(cls)
    setattr(bpy.types.Scene, f"{PREFIX}_props", bpy.props.PointerProperty(type=OBSPLANE_Properties))

def unregister():
    for cls in reversed(classes):
        if hasattr(bpy.types, cls.__name__): bpy.utils.unregister_class(cls)
    if hasattr(bpy.types.Scene, f"{PREFIX}_props"): delattr(bpy.types.Scene, f"{PREFIX}_props")

if __name__ == "__main__":
    register()