blender Million 2026

角度情報 20260405












# Copied: 2026-04-05 12:00:00
import bpy
import bmesh
import webbrowser
import math
import mathutils
from bpy.props import FloatVectorProperty, FloatProperty, BoolProperty, PointerProperty, StringProperty
from bpy.types import Operator, Panel, PropertyGroup
from mathutils import Vector
from datetime import datetime

# ==============================================================================
#  設定エリア & ID管理
# ==============================================================================

PREFIX = "Sphere20260227"
TAB_NAME = "   [ Sphere Angle ]   "

# ★ このスクリプト自身のID (絶対に消さないこと)
SOURCE_ID_TAG = "### ZIONAD_SOURCE_ID: SPHERE_2026_02_27_FIXED ###"

bl_info = {
    "name": f"zionad 520 [ Sphere Angle ] {PREFIX}",
    "author": "zionadchat",
    "version": (5, 0, 0),  # ★ 完全安定版 v5.0.0
    "blender": (3, 0, 0),
    "location": "3D View > Sidebar",
    "description": "3 Spheres & 2 Arrows Angle Tool (Stable Edition)",
    "category": "3D View",
}

OP_PREFIX = PREFIX.lower()
PROPS_NAME = f"{PREFIX}_props_v11"

ADDON_LINKS = (
    {"label": "角度情報 20260405", "url": "<https://www.notion.so/20260405-338f5dacaf4380afa9a9f565e52f966a>"},
    {"label": "Code Copy Template", "url": "<https://www.notion.so/Code-copy-20260221>"},
    {"label": "Theory Background", "url": "<https://www.notion.so/Einstein-from-20260119>"},
)

# ==============================================================================
#  デフォルト値設定
# ==============================================================================
# <BEGIN_DICT>
CURRENT_DEFAULTS = {
    "show_preview": True,
    "origin_pt": (0.0000, 0.0000, 0.0000),
    "pt_a": (5.0000, 0.0000, 0.0000),
    "pt_b": (0.0000, 5.0000, 0.0000),
    "origin_color": (1.0000, 1.0000, 1.0000, 0.8000),
    "origin_radius": 0.5000,
    "pt_a_color": (1.0000, 0.1000, 0.1000, 0.8000),
    "pt_a_radius": 0.5000,
    "pt_b_color": (0.1000, 0.3000, 1.0000, 0.8000),
    "pt_b_radius": 0.5000,
    "arrow_a_color": (1.0000, 0.5000, 0.0000, 0.8000),
    "arrow_a_thickness": 0.1500,
    "arrow_b_color": (0.0000, 0.8000, 1.0000, 0.8000),
    "arrow_b_thickness": 0.1500,
}
# <END_DICT>

# ==============================================================================
#  データ クリーンアップ管理 (安全化適用)
# ==============================================================================

PREVIEW_COL_NAME = f"{PREFIX}_Preview_Zone"
PREVIEW_MATS = [
    f"PreviewMat_Origin_{PREFIX}",
    f"PreviewMat_PtA_{PREFIX}",
    f"PreviewMat_PtB_{PREFIX}",
    f"PreviewMat_ArrowA_{PREFIX}",
    f"PreviewMat_ArrowB_{PREFIX}"
]

def safe_remove_object(obj):
    if not obj: return
    mesh = obj.data
    try: bpy.data.objects.remove(obj, do_unlink=True)
    except: pass

    if mesh and mesh.users == 0:
        try: bpy.data.meshes.remove(mesh)
        except: pass

def cleanup_preview_data():
    col = bpy.data.collections.get(PREVIEW_COL_NAME)
    if col:
        for obj in list(col.objects):
            safe_remove_object(obj)
        try: bpy.data.collections.remove(col)
        except: pass
        
    for mat_name in PREVIEW_MATS:
        mat = bpy.data.materials.get(mat_name)
        if mat and mat.users == 0:
            try: bpy.data.materials.remove(mat)
            except: pass

# ==============================================================================
#  数学計算用関数
# ==============================================================================

def get_plane_equation_str(O, A, B):
    try:
        vec_a = A - O
        vec_b = B - O
        n = vec_a.cross(vec_b)
        if n.length > 1e-6:
            n.normalize()
            d = -n.dot(O)
            def fmt(val, is_first=False):
                if abs(val) < 1e-5: val = 0.0
                if is_first: return f"{val:.3f}"
                return f"+ {val:.3f}" if val >= 0 else f"- {abs(val):.3f}"
            return f"{fmt(n.x, True)}x {fmt(n.y)}y {fmt(n.z)}z {fmt(d)} = 0"
    except: pass
    return "Undefined (Collinear)"

# ==============================================================================
#  マテリアル作成ロジック (ノード非破壊化 適用)
# ==============================================================================

def ensure_bsdf(mat):
    nodes = mat.node_tree.nodes
    links = mat.node_tree.links

    bsdf = next((n for n in nodes if n.type == 'BSDF_PRINCIPLED'), None)
    out = next((n for n in nodes if n.type == 'OUTPUT_MATERIAL'), None)

    if not bsdf: bsdf = nodes.new("ShaderNodeBsdfPrincipled")
    if not out: out = nodes.new("ShaderNodeOutputMaterial")

    if not bsdf.outputs[0].is_linked:
        links.new(bsdf.outputs[0], out.inputs[0])

    return bsdf

def create_unique_material(color, name_prefix="Mat"):
    timestamp = datetime.now().strftime('%M%S%f')[:5] 
    mat_name = f"{name_prefix}_{timestamp}"
    mat = bpy.data.materials.new(name=mat_name)
    mat.use_nodes = True
    mat.blend_method = 'BLEND'
    mat.diffuse_color = color  
    
    if mat.use_nodes:
        bsdf = ensure_bsdf(mat)
        if "Base Color" in bsdf.inputs: bsdf.inputs['Base Color'].default_value = color
        if "Alpha" in bsdf.inputs: bsdf.inputs['Alpha'].default_value = color[3]
    return mat

def get_or_create_preview_material(mat_name):
    mat = bpy.data.materials.get(mat_name)
    if not mat:
        mat = bpy.data.materials.new(name=mat_name)
        mat.use_nodes = True
        mat.blend_method = 'BLEND'
    return mat

def update_preview_material(mat, color):
    mat.diffuse_color = color  
    if mat.use_nodes:
        bsdf = ensure_bsdf(mat)
        if "Base Color" in bsdf.inputs: bsdf.inputs["Base Color"].default_value = color
        if "Alpha" in bsdf.inputs: bsdf.inputs["Alpha"].default_value = color[3]

# ==============================================================================
#  プレビュー用ロジック (コレクション&回転安全化 適用)
# ==============================================================================

def update_preview_geometry(context):
    props = getattr(context.scene, PROPS_NAME, None)
    if not props: return

    col = bpy.data.collections.get(PREVIEW_COL_NAME)
    if not col:
        col = bpy.data.collections.new(PREVIEW_COL_NAME)
        # ★ Collection存在チェック強化
        if col.name not in [c.name for c in context.scene.collection.children]:
            context.scene.collection.children.link(col)
            
    for obj in list(col.objects):
        safe_remove_object(obj)
            
    if not props.show_preview: return

    def create_prev_obj(name_suffix, bm, mat_name, color):
        mesh = bpy.data.meshes.new(f"PreviewMesh_{name_suffix}")
        bm.to_mesh(mesh)
        bm.free()
        obj = bpy.data.objects.new(f"[Preview] {name_suffix}", mesh)
        col.objects.link(obj)
        
        mat = get_or_create_preview_material(mat_name)
        update_preview_material(mat, color)
        obj.data.materials.append(mat)

    # ★ Vector最適化適用
    O_vec = Vector(props.origin_pt).copy()
    A_vec = Vector(props.pt_a).copy()
    B_vec = Vector(props.pt_b).copy()

    # 1. 3つのSpheres
    spheres_data = [
        (O_vec, props.origin_radius, props.origin_color, "Origin", PREVIEW_MATS[0]),
        (A_vec, props.pt_a_radius, props.pt_a_color, "PtA", PREVIEW_MATS[1]),
        (B_vec, props.pt_b_radius, props.pt_b_color, "PtB", PREVIEW_MATS[2]),
    ]
    for pt, r, c, name, mat_name in spheres_data:
        bm = bmesh.new()
        bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=max(r, 0.001))
        bmesh.ops.translate(bm, vec=pt, verts=bm.verts)
        create_prev_obj(name, bm, mat_name, c)

    # 2. 2つのArrows
    arrows_data = [
        (A_vec, props.arrow_a_thickness, props.arrow_a_color, "ArrowA", PREVIEW_MATS[3]),
        (B_vec, props.arrow_b_thickness, props.arrow_b_color, "ArrowB", PREVIEW_MATS[4]),
    ]
    for pt, thick, c, name, mat_name in arrows_data:
        bm = bmesh.new()
        vec = pt - O_vec
        length = vec.length
        
        # ★ NaN対策 (回転安全化)
        if length > 1e-6:
            Z = Vector((0,0,1))
            try: rot = Z.rotation_difference(vec).to_matrix().to_4x4()
            except: rot = mathutils.Matrix.Identity(4)
        else:
            rot = mathutils.Matrix.Identity(4)
            
        head_len = min(length * 0.2, thick * 6)
        body_len = length - head_len
        
        if body_len > 0:
            c_pos = O_vec + vec.normalized() * (body_len / 2)
            mat_body = mathutils.Matrix.Translation(c_pos) @ rot
            geom = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick, radius2=thick, depth=body_len)
            bmesh.ops.transform(bm, matrix=mat_body, verts=geom['verts'])
            
        if length > 1e-6:
            c_pos_head = O_vec + vec.normalized() * (body_len + head_len / 2)
            mat_head = mathutils.Matrix.Translation(c_pos_head) @ rot
            geom_head = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick*2.5, radius2=0, depth=head_len)
            bmesh.ops.transform(bm, matrix=mat_head, verts=geom_head['verts'])

        create_prev_obj(name, bm, mat_name, c)

# ==============================================================================
#  タイマー管理 (暴走防止適用)
# ==============================================================================

_timer = None

def delayed_update_safe():
    global _timer
    if not bpy.context or not getattr(bpy.context, "scene", None):
        _timer = None
        return None

    try:
        update_preview_geometry(bpy.context)
    except Exception as e:
        print("Preview update error:", e)

    _timer = None
    return None

def on_update(self, context):
    global _timer
    if _timer:
        try: bpy.app.timers.unregister(_timer)
        except: pass
        _timer = None
    _timer = bpy.app.timers.register(delayed_update_safe, first_interval=0.05)

# ==============================================================================
#  PROPERTIES
# ==============================================================================

class PG_SphereProps(PropertyGroup):
    show_preview: BoolProperty(name="Show Preview", default=CURRENT_DEFAULTS['show_preview'], update=on_update)
    
    origin_pt: FloatVectorProperty(name="Origin", size=3, default=CURRENT_DEFAULTS['origin_pt'], update=on_update)
    pt_a: FloatVectorProperty(name="Point A", size=3, default=CURRENT_DEFAULTS['pt_a'], update=on_update)
    pt_b: FloatVectorProperty(name="Point B", size=3, default=CURRENT_DEFAULTS['pt_b'], update=on_update)
    
    origin_color: FloatVectorProperty(name="Origin Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['origin_color'], update=on_update)
    origin_radius: FloatProperty(name="Origin Radius", default=CURRENT_DEFAULTS['origin_radius'], min=0.01, update=on_update)
    
    pt_a_color: FloatVectorProperty(name="Point A Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['pt_a_color'], update=on_update)
    pt_a_radius: FloatProperty(name="Point A Radius", default=CURRENT_DEFAULTS['pt_a_radius'], min=0.01, update=on_update)
    
    pt_b_color: FloatVectorProperty(name="Point B Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['pt_b_color'], update=on_update)
    pt_b_radius: FloatProperty(name="Point B Radius", default=CURRENT_DEFAULTS['pt_b_radius'], min=0.01, update=on_update)
    
    arrow_a_color: FloatVectorProperty(name="Arrow A Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['arrow_a_color'], update=on_update)
    arrow_a_thickness: FloatProperty(name="Arrow A Thick", default=CURRENT_DEFAULTS['arrow_a_thickness'], min=0.001, update=on_update)

    arrow_b_color: FloatVectorProperty(name="Arrow B Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['arrow_b_color'], update=on_update)
    arrow_b_thickness: FloatProperty(name="Arrow B Thick", default=CURRENT_DEFAULTS['arrow_b_thickness'], min=0.001, update=on_update)

# ==============================================================================
#  OPERATORS
# ==============================================================================

class OT_CreateAngleObjects(Operator):
    bl_idname = f"{OP_PREFIX}.create_objects"
    bl_label = "Create Objects"
    bl_options = {'REGISTER', 'UNDO'}
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        timestamp = datetime.now().strftime('%H%M%S')
        
        O_vec = Vector(props.origin_pt).copy()
        
        def create_sphere_obj(name, loc, radius, color):
            bm = bmesh.new()
            bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=max(radius, 0.001))
            bmesh.ops.translate(bm, vec=loc, verts=bm.verts)
            mesh = bpy.data.meshes.new(f"{name}_Mesh")
            bm.to_mesh(mesh)
            bm.free()
            obj = bpy.data.objects.new(f"{name}_{timestamp}", mesh)
            if context.collection: context.collection.objects.link(obj)
            else: context.scene.collection.objects.link(obj)
            mat = create_unique_material(color, f"Mat_{name}")
            obj.data.materials.append(mat)
            return obj

        def create_arrow_obj(name, loc_from, loc_to, thick, color):
            bm = bmesh.new()
            vec = loc_to - loc_from
            length = vec.length
            
            if length > 1e-6:
                Z = Vector((0,0,1))
                try: rot = Z.rotation_difference(vec).to_matrix().to_4x4()
                except: rot = mathutils.Matrix.Identity(4)
            else:
                rot = mathutils.Matrix.Identity(4)
                
            head_len = min(length * 0.2, thick * 6)
            body_len = length - head_len
            
            if body_len > 0:
                c_pos = loc_from + vec.normalized() * (body_len / 2)
                mat_body = mathutils.Matrix.Translation(c_pos) @ rot
                geom = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick, radius2=thick, depth=body_len)
                bmesh.ops.transform(bm, matrix=mat_body, verts=geom['verts'])
                
            if length > 1e-6:
                c_pos_head = loc_from + vec.normalized() * (body_len + head_len / 2)
                mat_head = mathutils.Matrix.Translation(c_pos_head) @ rot
                geom_head = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick*2.5, radius2=0, depth=head_len)
                bmesh.ops.transform(bm, matrix=mat_head, verts=geom_head['verts'])

            mesh = bpy.data.meshes.new(f"{name}_Mesh")
            bm.to_mesh(mesh)
            bm.free()
            obj = bpy.data.objects.new(f"{name}_{timestamp}", mesh)
            if context.collection: context.collection.objects.link(obj)
            else: context.scene.collection.objects.link(obj)
            mat = create_unique_material(color, f"Mat_{name}")
            obj.data.materials.append(mat)
            return obj

        bpy.ops.object.select_all(action='DESELECT')
        
        objs = []
        objs.append(create_sphere_obj("Origin", O_vec, props.origin_radius, props.origin_color))
        objs.append(create_sphere_obj("PointA", Vector(props.pt_a).copy(), props.pt_a_radius, props.pt_a_color))
        objs.append(create_sphere_obj("PointB", Vector(props.pt_b).copy(), props.pt_b_radius, props.pt_b_color))
        objs.append(create_arrow_obj("ArrowA", O_vec, Vector(props.pt_a).copy(), props.arrow_a_thickness, props.arrow_a_color))
        objs.append(create_arrow_obj("ArrowB", O_vec, Vector(props.pt_b).copy(), props.arrow_b_thickness, props.arrow_b_color))
        
        for o in objs: o.select_set(True)
        if objs: context.view_layer.objects.active = objs[-1]
        
        self.report({'INFO'}, "Created Spheres & Arrows with Unique Materials!")
        return {'FINISHED'}

class OT_CopyFullScript(Operator):
    bl_idname = f"{OP_PREFIX}.copy_script"
    bl_label = "Copy Script"
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        target_text = None
        for t in bpy.data.texts:
            if SOURCE_ID_TAG in t.as_string(): target_text = t; break
        
        if not target_text:
            self.report({'WARNING'}, "Source script not found in Text Editor.")
            return {'CANCELLED'}

        code = target_text.as_string()
        oc, ac, bc = props.origin_color, props.pt_a_color, props.pt_b_color
        aac, abc = props.arrow_a_color, props.arrow_b_color
        
        new_dict = "CURRENT_DEFAULTS = {\n"
        new_dict += f'    "show_preview": {props.show_preview},\n'
        new_dict += f'    "origin_pt": ({props.origin_pt[0]:.4f}, {props.origin_pt[1]:.4f}, {props.origin_pt[2]:.4f}),\n'
        new_dict += f'    "pt_a": ({props.pt_a[0]:.4f}, {props.pt_a[1]:.4f}, {props.pt_a[2]:.4f}),\n'
        new_dict += f'    "pt_b": ({props.pt_b[0]:.4f}, {props.pt_b[1]:.4f}, {props.pt_b[2]:.4f}),\n'
        new_dict += f'    "origin_color": ({oc[0]:.4f}, {oc[1]:.4f}, {oc[2]:.4f}, {oc[3]:.4f}),\n'
        new_dict += f'    "origin_radius": {props.origin_radius:.4f},\n'
        new_dict += f'    "pt_a_color": ({ac[0]:.4f}, {ac[1]:.4f}, {ac[2]:.4f}, {ac[3]:.4f}),\n'
        new_dict += f'    "pt_a_radius": {props.pt_a_radius:.4f},\n'
        new_dict += f'    "pt_b_color": ({bc[0]:.4f}, {bc[1]:.4f}, {bc[2]:.4f}, {bc[3]:.4f}),\n'
        new_dict += f'    "pt_b_radius": {props.pt_b_radius:.4f},\n'
        new_dict += f'    "arrow_a_color": ({aac[0]:.4f}, {aac[1]:.4f}, {aac[2]:.4f}, {aac[3]:.4f}),\n'
        new_dict += f'    "arrow_a_thickness": {props.arrow_a_thickness:.4f},\n'
        new_dict += f'    "arrow_b_color": ({abc[0]:.4f}, {abc[1]:.4f}, {abc[2]:.4f}, {abc[3]:.4f}),\n'
        new_dict += f'    "arrow_b_thickness": {props.arrow_b_thickness:.4f},\n'
        new_dict += "}\n"

        try:
            tag_start = "# <BEGIN" + "_DICT>"
            tag_end = "# <END" + "_DICT>"
            if tag_start not in code or tag_end not in code: return {'CANCELLED'}
            pre_code, rest = code.split(tag_start, 1)
            _, post_code = rest.split(tag_end, 1)
            final_code = pre_code + tag_start + "\n" + new_dict + tag_end + post_code
            
            lines = final_code.split("\n")
            if len(lines) > 0 and lines[0].startswith("# Copied:"):
                lines[0] = f"# Copied: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
            
            context.window_manager.clipboard = "\n".join(lines)
            self.report({'INFO'}, "Code copied with absolute safety!")
        except Exception as e: 
            self.report({'ERROR'}, f"Copy failed: {e}")
            return {'CANCELLED'}
        return {'FINISHED'}

class OT_CopyAngleInfo(Operator):
    bl_idname = f"{OP_PREFIX}.copy_angle_info"
    bl_label = "Copy Angle Info"
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        if not props: return {'CANCELLED'}
        
        O = Vector(props.origin_pt).copy()
        A = Vector(props.pt_a).copy()
        B = Vector(props.pt_b).copy()
        
        vec_a = A - O
        vec_b = B - O
        
        def get_angles(v):
            if v.length < 1e-6: return 0.0, 0.0, 0.0
            try:
                vx = math.degrees(v.angle(Vector((1,0,0))))
                vy = math.degrees(v.angle(Vector((0,1,0))))
                vz = math.degrees(v.angle(Vector((0,0,1))))
            except: return 0.0, 0.0, 0.0
            return vx, vy, vz
            
        ax, ay, az = get_angles(vec_a)
        bx, by, bz = get_angles(vec_b)
        
        angle_ab = 0.0
        if vec_a.length > 1e-6 and vec_b.length > 1e-6:
            try: angle_ab = math.degrees(vec_a.angle(vec_b))
            except: pass
            
        plane_str = get_plane_equation_str(O, A, B)
            
        text = (
            f"【角度・平面情報】\n"
            f"■基点(Orig): ({O.x:.4f}, {O.y:.4f}, {O.z:.4f})\n"
            f"■点A: ({A.x:.4f}, {A.y:.4f}, {A.z:.4f})\n"
            f"  - 距離: {vec_a.length:.4f}\n"
            f"  - X軸との角度: {ax:.2f}°\n"
            f"  - Y軸との角度: {ay:.2f}°\n"
            f"  - Z軸との角度: {az:.2f}°\n"
            f"■点B: ({B.x:.4f}, {B.y:.4f}, {B.z:.4f})\n"
            f"  - 距離: {vec_b.length:.4f}\n"
            f"  - X軸との角度: {bx:.2f}°\n"
            f"  - Y軸との角度: {by:.2f}°\n"
            f"  - Z軸との角度: {bz:.2f}°\n"
            f"■2つの矢印のなす角 (A - Orig - B): {angle_ab:.2f}°\n"
            f"■平面方程式 (ax + by + cz + d = 0): \n"
            f"  {plane_str}\n"
        )
        
        context.window_manager.clipboard = text
        self.report({'INFO'}, "Copied Angle & Plane Info to clipboard")
        return {'FINISHED'}

class OT_OpenUrl(Operator):
    bl_idname = f"{OP_PREFIX}.open_url"; bl_label = "Open URL"; url: StringProperty()
    def execute(self, context): webbrowser.open(self.url); return {'FINISHED'}

class OT_RemoveAddon(Operator):
    bl_idname = f"{OP_PREFIX}.remove_addon"; bl_label = "Remove Addon"
    def execute(self, context):
        bpy.app.timers.register(lambda: unregister(), first_interval=0.1)
        return {'FINISHED'}

# ==============================================================================
#  PANELS
# ==============================================================================

class PT_MainPanel(Panel):
    bl_label = "Sphere Angle Tool"
    bl_idname = f"{PREFIX}_PT_main"
    bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME

    def draw(self, context):
        layout = self.layout
        props = getattr(context.scene, PROPS_NAME, None)
        if not props: layout.label(text="Reload Script"); return

        row = layout.row()
        row.scale_y = 1.2
        row.operator(OT_CopyFullScript.bl_idname, icon='COPY_ID', text="Copy Code with Values")
        layout.separator()

        layout.prop(props, "show_preview", icon='RESTRICT_VIEW_OFF' if props.show_preview else 'RESTRICT_VIEW_ON')
        
        box_a = layout.box()
        box_a.label(text="Coordinates", icon='DRIVER_TRANSFORM')
        box_a.prop(props, "origin_pt", text="Origin (O)")
        box_a.prop(props, "pt_a", text="Point A")
        box_a.prop(props, "pt_b", text="Point B")
        box_a.separator()
        
        O = Vector(props.origin_pt).copy()
        A = Vector(props.pt_a).copy()
        B = Vector(props.pt_b).copy()
        vec_a = A - O
        vec_b = B - O
        
        col_a = box_a.column(align=True)
        angle_ab = 0.0
        if vec_a.length > 1e-6 and vec_b.length > 1e-6:
            try: angle_ab = math.degrees(vec_a.angle(vec_b))
            except: pass
        col_a.label(text=f"Angle (A-O-B): {angle_ab:.2f}°", icon='DRIVER_ROTATIONAL_DIFFERENCE')

        plane_str = get_plane_equation_str(O, A, B)
        col_a.label(text=f"Plane Eq: {plane_str}", icon='MESH_PLANE')
        
        box_a.separator()
        box_a.operator(OT_CopyAngleInfo.bl_idname, icon='COPYDOWN', text="Copy Full Angle Info")

        if props.show_preview:
            b2 = layout.box()
            b2.label(text="Size & Color Settings", icon='COLOR')
            
            b2.label(text="Origin Sphere", icon='DOT')
            r1 = b2.row(align=True)
            r1.prop(props, "origin_radius", text="Radius")
            r1.prop(props, "origin_color", text="")

            b2.label(text="Point A Sphere", icon='DOT')
            r2 = b2.row(align=True)
            r2.prop(props, "pt_a_radius", text="Radius")
            r2.prop(props, "pt_a_color", text="")

            b2.label(text="Point B Sphere", icon='DOT')
            r3 = b2.row(align=True)
            r3.prop(props, "pt_b_radius", text="Radius")
            r3.prop(props, "pt_b_color", text="")
            
            b2.separator()
            b2.label(text="Arrow A (Origin -> A)", icon='FORWARD')
            r4 = b2.row(align=True)
            r4.prop(props, "arrow_a_thickness", text="Thickness")
            r4.prop(props, "arrow_a_color", text="")

            b2.label(text="Arrow B (Origin -> B)", icon='FORWARD')
            r5 = b2.row(align=True)
            r5.prop(props, "arrow_b_thickness", text="Thickness")
            r5.prop(props, "arrow_b_color", text="")

        layout.separator()
        col_exec = layout.column()
        col_exec.scale_y = 1.5
        col_exec.operator(OT_CreateAngleObjects.bl_idname, icon='MESH_UVSPHERE', text="Create 3 Spheres & 2 Arrows")

class PT_LinksPanel(Panel):
    bl_label = "Links"; bl_idname = f"{PREFIX}_PT_links"; bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME; bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context):
        for l in ADDON_LINKS: self.layout.operator(OT_OpenUrl.bl_idname, text=l["label"]).url = l["url"]

class PT_RemovePanel(Panel):
    bl_label = "System"; bl_idname = f"{PREFIX}_PT_remove"; bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME; bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context): self.layout.operator(OT_RemoveAddon.bl_idname, icon='CANCEL', text="Remove Addon")

# ==============================================================================
#  REGISTER (Unregister クリーン化適用)
# ==============================================================================

classes = (
    PG_SphereProps, OT_CreateAngleObjects, OT_CopyFullScript, OT_CopyAngleInfo, 
    OT_OpenUrl, OT_RemoveAddon, 
    PT_MainPanel, PT_LinksPanel, PT_RemovePanel
)

def auto_open_sidebar():
    try:
        for window in bpy.context.window_manager.windows:
            for area in window.screen.areas:
                if area.type == 'VIEW_3D':
                    for space in area.spaces:
                        if space.type == 'VIEW_3D':
                            if not space.show_region_ui:
                                space.show_region_ui = True
    except: pass
    return None

def register():
    for c in classes: 
        try: bpy.utils.register_class(c)
        except: pass
    setattr(bpy.types.Scene, PROPS_NAME, PointerProperty(type=PG_SphereProps))
    bpy.app.timers.register(auto_open_sidebar, first_interval=0.1)

def unregister():
    global _timer
    if _timer:
        try: bpy.app.timers.unregister(_timer)
        except: pass
        _timer = None

    cleanup_preview_data()

    try:
        if hasattr(bpy.types.Scene, PROPS_NAME): 
            delattr(bpy.types.Scene, PROPS_NAME)
    except: pass

    for c in reversed(classes): 
        try: bpy.utils.unregister_class(c)
        except: pass

if __name__ == "__main__": 
    register()
# Copied: 2026-04-05 10:00:00
import bpy
import bmesh
import webbrowser
import math
import mathutils
from bpy.props import FloatVectorProperty, FloatProperty, BoolProperty, PointerProperty, StringProperty
from bpy.types import Operator, Panel, PropertyGroup
from mathutils import Vector
from datetime import datetime

# ==============================================================================
#  設定エリア & ID管理
# ==============================================================================

PREFIX = "Sphere20260227"
TAB_NAME = "   [ Sphere Angle ]   "

# ★ このスクリプト自身のID (絶対に消さないこと)
SOURCE_ID_TAG = "### ZIONAD_SOURCE_ID: SPHERE_2026_02_27_FIXED ###"

bl_info = {
    "name": f"zionad 520 [ Sphere Angle ] {PREFIX}",
    "author": "zionadchat",
    "version": (4, 1, 0),
    "blender": (3, 0, 0),
    "location": "3D View > Sidebar",
    "description": "3 Spheres & 2 Arrows Angle Tool",
    "category": "3D View",
}

OP_PREFIX = PREFIX.lower()
PROPS_NAME = f"{PREFIX}_props_v10"

# ★ リンクトップを更新しました
ADDON_LINKS = (
    {"label": "角度情報 20260405", "url": "<https://www.notion.so/20260405-338f5dacaf4380afa9a9f565e52f966a>"},
    {"label": "Code Copy Template", "url": "<https://www.notion.so/Code-copy-20260221>"},
    {"label": "Theory Background", "url": "<https://www.notion.so/Einstein-from-20260119>"},
)

# ==============================================================================
#  デフォルト値設定 (コピー機能でここが書き換わります)
# ==============================================================================
# <BEGIN_DICT>
CURRENT_DEFAULTS = {
    "show_preview": True,
    "origin_pt": (0.0000, 0.0000, 0.0000),
    "pt_a": (5.0000, 0.0000, 0.0000),
    "pt_b": (0.0000, 5.0000, 0.0000),
    "origin_color": (1.0000, 1.0000, 1.0000, 0.8000),
    "origin_radius": 0.5000,
    "pt_a_color": (1.0000, 0.1000, 0.1000, 0.8000),
    "pt_a_radius": 0.5000,
    "pt_b_color": (0.1000, 0.3000, 1.0000, 0.8000),
    "pt_b_radius": 0.5000,
    "arrow_a_color": (1.0000, 0.5000, 0.0000, 0.8000),
    "arrow_a_thickness": 0.1500,
    "arrow_b_color": (0.0000, 0.8000, 1.0000, 0.8000),
    "arrow_b_thickness": 0.1500,
}
# <END_DICT>

# ==============================================================================
#  データ クリーンアップ管理
# ==============================================================================

PREVIEW_COL_NAME = f"{PREFIX}_Preview_Zone"

PREVIEW_MATS = [
    f"PreviewMat_Origin_{PREFIX}",
    f"PreviewMat_PtA_{PREFIX}",
    f"PreviewMat_PtB_{PREFIX}",
    f"PreviewMat_ArrowA_{PREFIX}",
    f"PreviewMat_ArrowB_{PREFIX}"
]

def cleanup_preview_data():
    col = bpy.data.collections.get(PREVIEW_COL_NAME)
    if col:
        for obj in list(col.objects):
            mesh = obj.data
            bpy.data.objects.remove(obj, do_unlink=True)
            if mesh and mesh.users == 0:
                bpy.data.meshes.remove(mesh)
        bpy.data.collections.remove(col)
        
    for mat_name in PREVIEW_MATS:
        mat = bpy.data.materials.get(mat_name)
        if mat and mat.users == 0:
            bpy.data.materials.remove(mat)

# ==============================================================================
#  数学計算用関数(平面方程式など)エラー保護付き
# ==============================================================================

def get_plane_equation_str(O, A, B):
    try:
        vec_a = A - O
        vec_b = B - O
        n = vec_a.cross(vec_b)
        if n.length > 1e-6:
            n.normalize()
            d = -n.dot(O)
            def fmt(val, is_first=False):
                if abs(val) < 1e-5: val = 0.0
                if is_first: return f"{val:.3f}"
                return f"+ {val:.3f}" if val >= 0 else f"- {abs(val):.3f}"
            return f"{fmt(n.x, True)}x {fmt(n.y)}y {fmt(n.z)}z {fmt(d)} = 0"
    except: pass
    return "Undefined (Collinear)"

# ==============================================================================
#  マテリアル作成ロジック (ソリッドモード完全対応)
# ==============================================================================

def create_unique_material(color, name_prefix="Mat"):
    timestamp = datetime.now().strftime('%M%S%f')[:5] 
    mat_name = f"{name_prefix}_{timestamp}"
    mat = bpy.data.materials.new(name=mat_name)
    mat.use_nodes = True
    mat.blend_method = 'BLEND'
    mat.diffuse_color = color  
    
    if mat.use_nodes:
        tree = mat.node_tree
        tree.nodes.clear()
        bsdf = tree.nodes.new("ShaderNodeBsdfPrincipled")
        bsdf.location = (0, 0)
        out = tree.nodes.new("ShaderNodeOutputMaterial")
        out.location = (300, 0)
        tree.links.new(bsdf.outputs[0], out.inputs[0])
        
        if "Base Color" in bsdf.inputs: bsdf.inputs['Base Color'].default_value = color
        if "Alpha" in bsdf.inputs: bsdf.inputs['Alpha'].default_value = color[3]
    return mat

def get_or_create_preview_material(mat_name):
    mat = bpy.data.materials.get(mat_name)
    if not mat:
        mat = bpy.data.materials.new(name=mat_name)
        mat.use_nodes = True
        mat.blend_method = 'BLEND'
    return mat

def update_preview_material(mat, color):
    mat.diffuse_color = color  
    if mat.use_nodes:
        bsdf = None
        for node in mat.node_tree.nodes:
            if node.type == 'BSDF_PRINCIPLED':
                bsdf = node
                break
        if not bsdf:
            mat.node_tree.nodes.clear()
            bsdf = mat.node_tree.nodes.new("ShaderNodeBsdfPrincipled")
            out = mat.node_tree.nodes.new("ShaderNodeOutputMaterial")
            mat.node_tree.links.new(bsdf.outputs[0], out.inputs[0])
            
        if "Base Color" in bsdf.inputs: bsdf.inputs["Base Color"].default_value = color
        if "Alpha" in bsdf.inputs: bsdf.inputs["Alpha"].default_value = color[3]

# ==============================================================================
#  プレビュー用ロジック (★独立したオブジェクトとして生成・色が混ざらない)
# ==============================================================================

def update_preview_geometry(context):
    props = getattr(context.scene, PROPS_NAME, None)
    if not props: return

    col = bpy.data.collections.get(PREVIEW_COL_NAME)
    if not col:
        col = bpy.data.collections.new(PREVIEW_COL_NAME)
        if col.name not in context.scene.collection.children:
            context.scene.collection.children.link(col)
            
    # 古いプレビューを完全にリセット
    for obj in list(col.objects):
        mesh = obj.data
        bpy.data.objects.remove(obj, do_unlink=True)
        if mesh and mesh.users == 0:
            bpy.data.meshes.remove(mesh)
            
    if not props.show_preview: return

    def create_prev_obj(name_suffix, bm, mat_name, color):
        mesh = bpy.data.meshes.new(f"PreviewMesh_{name_suffix}")
        bm.to_mesh(mesh)
        bm.free()
        obj = bpy.data.objects.new(f"[Preview] {name_suffix}", mesh)
        col.objects.link(obj)
        
        mat = get_or_create_preview_material(mat_name)
        update_preview_material(mat, color)
        obj.data.materials.append(mat)

    # 1. 3つのSpheres
    spheres_data = [
        (props.origin_pt, props.origin_radius, props.origin_color, "Origin", PREVIEW_MATS[0]),
        (props.pt_a, props.pt_a_radius, props.pt_a_color, "PtA", PREVIEW_MATS[1]),
        (props.pt_b, props.pt_b_radius, props.pt_b_color, "PtB", PREVIEW_MATS[2]),
    ]
    for pt, r, c, name, mat_name in spheres_data:
        bm = bmesh.new()
        bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=max(r, 0.001))
        bmesh.ops.translate(bm, vec=Vector(pt), verts=bm.verts)
        create_prev_obj(name, bm, mat_name, c)

    # 2. 2つのArrows
    O = Vector(props.origin_pt)
    arrows_data = [
        (props.pt_a, props.arrow_a_thickness, props.arrow_a_color, "ArrowA", PREVIEW_MATS[3]),
        (props.pt_b, props.arrow_b_thickness, props.arrow_b_color, "ArrowB", PREVIEW_MATS[4]),
    ]
    for pt, thick, c, name, mat_name in arrows_data:
        bm = bmesh.new()
        target = Vector(pt)
        vec = target - O
        length = vec.length
        if length > 1e-4:
            Z = Vector((0,0,1))
            try: rot = Z.rotation_difference(vec).to_matrix().to_4x4()
            except: rot = mathutils.Matrix.Identity(4)
            
            head_len = min(length * 0.2, thick * 6)
            body_len = length - head_len
            
            if body_len > 0:
                c_pos = O + vec.normalized() * (body_len / 2)
                mat_body = mathutils.Matrix.Translation(c_pos) @ rot
                geom = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick, radius2=thick, depth=body_len)
                bmesh.ops.transform(bm, matrix=mat_body, verts=geom['verts'])
                
            c_pos_head = O + vec.normalized() * (body_len + head_len / 2)
            mat_head = mathutils.Matrix.Translation(c_pos_head) @ rot
            geom_head = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick*2.5, radius2=0, depth=head_len)
            bmesh.ops.transform(bm, matrix=mat_head, verts=geom_head['verts'])

        create_prev_obj(name, bm, mat_name, c)

_timer = None
def delayed_update():
    global _timer
    _timer = None
    if bpy.context and bpy.context.scene:
        update_preview_geometry(bpy.context)
    return None

def on_update(self, context):
    global _timer
    if _timer: 
        try: bpy.app.timers.unregister(_timer)
        except: pass
    _timer = bpy.app.timers.register(delayed_update, first_interval=0.05)

# ==============================================================================
#  PROPERTIES
# ==============================================================================

class PG_SphereProps(PropertyGroup):
    show_preview: BoolProperty(name="Show Preview", default=CURRENT_DEFAULTS['show_preview'], update=on_update)
    
    origin_pt: FloatVectorProperty(name="Origin", size=3, default=CURRENT_DEFAULTS['origin_pt'], update=on_update)
    pt_a: FloatVectorProperty(name="Point A", size=3, default=CURRENT_DEFAULTS['pt_a'], update=on_update)
    pt_b: FloatVectorProperty(name="Point B", size=3, default=CURRENT_DEFAULTS['pt_b'], update=on_update)
    
    origin_color: FloatVectorProperty(name="Origin Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['origin_color'], update=on_update)
    origin_radius: FloatProperty(name="Origin Radius", default=CURRENT_DEFAULTS['origin_radius'], min=0.01, update=on_update)
    
    pt_a_color: FloatVectorProperty(name="Point A Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['pt_a_color'], update=on_update)
    pt_a_radius: FloatProperty(name="Point A Radius", default=CURRENT_DEFAULTS['pt_a_radius'], min=0.01, update=on_update)
    
    pt_b_color: FloatVectorProperty(name="Point B Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['pt_b_color'], update=on_update)
    pt_b_radius: FloatProperty(name="Point B Radius", default=CURRENT_DEFAULTS['pt_b_radius'], min=0.01, update=on_update)
    
    arrow_a_color: FloatVectorProperty(name="Arrow A Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['arrow_a_color'], update=on_update)
    arrow_a_thickness: FloatProperty(name="Arrow A Thick", default=CURRENT_DEFAULTS['arrow_a_thickness'], min=0.001, update=on_update)

    arrow_b_color: FloatVectorProperty(name="Arrow B Color", subtype='COLOR', size=4, min=0, max=1, default=CURRENT_DEFAULTS['arrow_b_color'], update=on_update)
    arrow_b_thickness: FloatProperty(name="Arrow B Thick", default=CURRENT_DEFAULTS['arrow_b_thickness'], min=0.001, update=on_update)

# ==============================================================================
#  OPERATORS
# ==============================================================================

class OT_CreateAngleObjects(Operator):
    bl_idname = f"{OP_PREFIX}.create_objects"
    bl_label = "Create Objects"
    bl_options = {'REGISTER', 'UNDO'}
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        timestamp = datetime.now().strftime('%H%M%S')
        
        def create_sphere_obj(name, loc, radius, color):
            bm = bmesh.new()
            bmesh.ops.create_uvsphere(bm, u_segments=32, v_segments=16, radius=max(radius, 0.001))
            bmesh.ops.translate(bm, vec=Vector(loc), verts=bm.verts)
            mesh = bpy.data.meshes.new(f"{name}_Mesh")
            bm.to_mesh(mesh)
            bm.free()
            obj = bpy.data.objects.new(f"{name}_{timestamp}", mesh)
            if context.collection: context.collection.objects.link(obj)
            else: context.scene.collection.objects.link(obj)
            mat = create_unique_material(color, f"Mat_{name}")
            obj.data.materials.append(mat)
            return obj

        def create_arrow_obj(name, loc_from, loc_to, thick, color):
            bm = bmesh.new()
            vec = Vector(loc_to) - Vector(loc_from)
            length = vec.length
            if length > 1e-4:
                Z = Vector((0,0,1))
                try: rot = Z.rotation_difference(vec).to_matrix().to_4x4()
                except: rot = mathutils.Matrix.Identity(4)
                
                head_len = min(length * 0.2, thick * 6)
                body_len = length - head_len
                
                if body_len > 0:
                    c_pos = Vector(loc_from) + vec.normalized() * (body_len / 2)
                    mat_body = mathutils.Matrix.Translation(c_pos) @ rot
                    geom = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick, radius2=thick, depth=body_len)
                    bmesh.ops.transform(bm, matrix=mat_body, verts=geom['verts'])
                    
                c_pos_head = Vector(loc_from) + vec.normalized() * (body_len + head_len / 2)
                mat_head = mathutils.Matrix.Translation(c_pos_head) @ rot
                geom_head = bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=16, radius1=thick*2.5, radius2=0, depth=head_len)
                bmesh.ops.transform(bm, matrix=mat_head, verts=geom_head['verts'])

            mesh = bpy.data.meshes.new(f"{name}_Mesh")
            bm.to_mesh(mesh)
            bm.free()
            obj = bpy.data.objects.new(f"{name}_{timestamp}", mesh)
            if context.collection: context.collection.objects.link(obj)
            else: context.scene.collection.objects.link(obj)
            mat = create_unique_material(color, f"Mat_{name}")
            obj.data.materials.append(mat)
            return obj

        bpy.ops.object.select_all(action='DESELECT')
        
        objs = []
        objs.append(create_sphere_obj("Origin", props.origin_pt, props.origin_radius, props.origin_color))
        objs.append(create_sphere_obj("PointA", props.pt_a, props.pt_a_radius, props.pt_a_color))
        objs.append(create_sphere_obj("PointB", props.pt_b, props.pt_b_radius, props.pt_b_color))
        objs.append(create_arrow_obj("ArrowA", props.origin_pt, props.pt_a, props.arrow_a_thickness, props.arrow_a_color))
        objs.append(create_arrow_obj("ArrowB", props.origin_pt, props.pt_b, props.arrow_b_thickness, props.arrow_b_color))
        
        for o in objs: o.select_set(True)
        if objs: context.view_layer.objects.active = objs[-1]
        
        self.report({'INFO'}, "Created Spheres & Arrows with Unique Materials!")
        return {'FINISHED'}

class OT_CopyFullScript(Operator):
    bl_idname = f"{OP_PREFIX}.copy_script"
    bl_label = "Copy Script"
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        target_text = None
        for t in bpy.data.texts:
            if SOURCE_ID_TAG in t.as_string(): target_text = t; break
        
        if not target_text:
            self.report({'WARNING'}, "Source script not found in Text Editor.")
            return {'CANCELLED'}

        code = target_text.as_string()
        oc, ac, bc = props.origin_color, props.pt_a_color, props.pt_b_color
        aac, abc = props.arrow_a_color, props.arrow_b_color
        
        new_dict = "CURRENT_DEFAULTS = {\n"
        new_dict += f'    "show_preview": {props.show_preview},\n'
        new_dict += f'    "origin_pt": ({props.origin_pt[0]:.4f}, {props.origin_pt[1]:.4f}, {props.origin_pt[2]:.4f}),\n'
        new_dict += f'    "pt_a": ({props.pt_a[0]:.4f}, {props.pt_a[1]:.4f}, {props.pt_a[2]:.4f}),\n'
        new_dict += f'    "pt_b": ({props.pt_b[0]:.4f}, {props.pt_b[1]:.4f}, {props.pt_b[2]:.4f}),\n'
        new_dict += f'    "origin_color": ({oc[0]:.4f}, {oc[1]:.4f}, {oc[2]:.4f}, {oc[3]:.4f}),\n'
        new_dict += f'    "origin_radius": {props.origin_radius:.4f},\n'
        new_dict += f'    "pt_a_color": ({ac[0]:.4f}, {ac[1]:.4f}, {ac[2]:.4f}, {ac[3]:.4f}),\n'
        new_dict += f'    "pt_a_radius": {props.pt_a_radius:.4f},\n'
        new_dict += f'    "pt_b_color": ({bc[0]:.4f}, {bc[1]:.4f}, {bc[2]:.4f}, {bc[3]:.4f}),\n'
        new_dict += f'    "pt_b_radius": {props.pt_b_radius:.4f},\n'
        new_dict += f'    "arrow_a_color": ({aac[0]:.4f}, {aac[1]:.4f}, {aac[2]:.4f}, {aac[3]:.4f}),\n'
        new_dict += f'    "arrow_a_thickness": {props.arrow_a_thickness:.4f},\n'
        new_dict += f'    "arrow_b_color": ({abc[0]:.4f}, {abc[1]:.4f}, {abc[2]:.4f}, {abc[3]:.4f}),\n'
        new_dict += f'    "arrow_b_thickness": {props.arrow_b_thickness:.4f},\n'
        new_dict += "}\n"

        try:
            tag_start = "# <BEGIN" + "_DICT>"
            tag_end = "# <END" + "_DICT>"
            if tag_start not in code or tag_end not in code: return {'CANCELLED'}
            pre_code, rest = code.split(tag_start, 1)
            _, post_code = rest.split(tag_end, 1)
            final_code = pre_code + tag_start + "\n" + new_dict + tag_end + post_code
            
            lines = final_code.split("\n")
            if len(lines) > 0 and lines[0].startswith("# Copied:"):
                lines[0] = f"# Copied: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
            
            context.window_manager.clipboard = "\n".join(lines)
            self.report({'INFO'}, "Code copied with absolute safety!")
        except Exception as e: 
            self.report({'ERROR'}, f"Copy failed: {e}")
            return {'CANCELLED'}
        return {'FINISHED'}

class OT_CopyAngleInfo(Operator):
    bl_idname = f"{OP_PREFIX}.copy_angle_info"
    bl_label = "Copy Angle Info"
    
    def execute(self, context):
        props = getattr(context.scene, PROPS_NAME, None)
        if not props: return {'CANCELLED'}
        
        O = Vector(props.origin_pt)
        A = Vector(props.pt_a)
        B = Vector(props.pt_b)
        
        vec_a = A - O
        vec_b = B - O
        
        def get_angles(v):
            if v.length < 1e-6: return 0.0, 0.0, 0.0
            try:
                vx = math.degrees(v.angle(Vector((1,0,0))))
                vy = math.degrees(v.angle(Vector((0,1,0))))
                vz = math.degrees(v.angle(Vector((0,0,1))))
            except: return 0.0, 0.0, 0.0
            return vx, vy, vz
            
        ax, ay, az = get_angles(vec_a)
        bx, by, bz = get_angles(vec_b)
        
        angle_ab = 0.0
        if vec_a.length > 1e-6 and vec_b.length > 1e-6:
            try: angle_ab = math.degrees(vec_a.angle(vec_b))
            except: pass
            
        plane_str = get_plane_equation_str(O, A, B)
            
        text = (
            f"【角度・平面情報】\n"
            f"■基点(Orig): ({O.x:.4f}, {O.y:.4f}, {O.z:.4f})\n"
            f"■点A: ({A.x:.4f}, {A.y:.4f}, {A.z:.4f})\n"
            f"  - 距離: {vec_a.length:.4f}\n"
            f"  - X軸との角度: {ax:.2f}°\n"
            f"  - Y軸との角度: {ay:.2f}°\n"
            f"  - Z軸との角度: {az:.2f}°\n"
            f"■点B: ({B.x:.4f}, {B.y:.4f}, {B.z:.4f})\n"
            f"  - 距離: {vec_b.length:.4f}\n"
            f"  - X軸との角度: {bx:.2f}°\n"
            f"  - Y軸との角度: {by:.2f}°\n"
            f"  - Z軸との角度: {bz:.2f}°\n"
            f"■2つの矢印のなす角 (A - Orig - B): {angle_ab:.2f}°\n"
            f"■平面方程式 (ax + by + cz + d = 0): \n"
            f"  {plane_str}\n"
        )
        
        context.window_manager.clipboard = text
        self.report({'INFO'}, "Copied Angle & Plane Info to clipboard")
        return {'FINISHED'}

class OT_OpenUrl(Operator):
    bl_idname = f"{OP_PREFIX}.open_url"; bl_label = "Open URL"; url: StringProperty()
    def execute(self, context): webbrowser.open(self.url); return {'FINISHED'}

class OT_RemoveAddon(Operator):
    bl_idname = f"{OP_PREFIX}.remove_addon"; bl_label = "Remove Addon"
    def execute(self, context):
        bpy.app.timers.register(lambda: unregister(), first_interval=0.1)
        return {'FINISHED'}

# ==============================================================================
#  PANELS (すべて1つのパネルに集約)
# ==============================================================================

class PT_MainPanel(Panel):
    bl_label = "Sphere Angle Tool"
    bl_idname = f"{PREFIX}_PT_main"
    bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME

    def draw(self, context):
        layout = self.layout
        props = getattr(context.scene, PROPS_NAME, None)
        if not props: layout.label(text="Reload Script"); return

        # -----------------------------
        # 1. 共通ツール
        # -----------------------------
        row = layout.row()
        row.scale_y = 1.2
        row.operator(OT_CopyFullScript.bl_idname, icon='COPY_ID', text="Copy Code with Values")
        layout.separator()

        layout.prop(props, "show_preview", icon='RESTRICT_VIEW_OFF' if props.show_preview else 'RESTRICT_VIEW_ON')
        
        # -----------------------------
        # 2. 座標設定と角度計算表示
        # -----------------------------
        box_a = layout.box()
        box_a.label(text="Coordinates", icon='DRIVER_TRANSFORM')
        box_a.prop(props, "origin_pt", text="Origin (O)")
        box_a.prop(props, "pt_a", text="Point A")
        box_a.prop(props, "pt_b", text="Point B")
        box_a.separator()
        
        O = Vector(props.origin_pt)
        A = Vector(props.pt_a)
        B = Vector(props.pt_b)
        vec_a = A - O
        vec_b = B - O
        
        col_a = box_a.column(align=True)
        angle_ab = 0.0
        if vec_a.length > 1e-6 and vec_b.length > 1e-6:
            try: angle_ab = math.degrees(vec_a.angle(vec_b))
            except: pass
        col_a.label(text=f"Angle (A-O-B): {angle_ab:.2f}°", icon='DRIVER_ROTATIONAL_DIFFERENCE')

        plane_str = get_plane_equation_str(O, A, B)
        col_a.label(text=f"Plane Eq: {plane_str}", icon='MESH_PLANE')
        
        box_a.separator()
        box_a.operator(OT_CopyAngleInfo.bl_idname, icon='COPYDOWN', text="Copy Full Angle Info")

        # -----------------------------
        # 3. 色とサイズ(1行ずつ)
        # -----------------------------
        if props.show_preview:
            b2 = layout.box()
            b2.label(text="Size & Color Settings", icon='COLOR')
            
            b2.label(text="Origin Sphere", icon='DOT')
            r1 = b2.row(align=True)
            r1.prop(props, "origin_radius", text="Radius")
            r1.prop(props, "origin_color", text="")

            b2.label(text="Point A Sphere", icon='DOT')
            r2 = b2.row(align=True)
            r2.prop(props, "pt_a_radius", text="Radius")
            r2.prop(props, "pt_a_color", text="")

            b2.label(text="Point B Sphere", icon='DOT')
            r3 = b2.row(align=True)
            r3.prop(props, "pt_b_radius", text="Radius")
            r3.prop(props, "pt_b_color", text="")
            
            b2.separator()
            b2.label(text="Arrow A (Origin -> A)", icon='FORWARD')
            r4 = b2.row(align=True)
            r4.prop(props, "arrow_a_thickness", text="Thickness")
            r4.prop(props, "arrow_a_color", text="")

            b2.label(text="Arrow B (Origin -> B)", icon='FORWARD')
            r5 = b2.row(align=True)
            r5.prop(props, "arrow_b_thickness", text="Thickness")
            r5.prop(props, "arrow_b_color", text="")

        # -----------------------------
        # 4. 実体化ボタン
        # -----------------------------
        layout.separator()
        col_exec = layout.column()
        col_exec.scale_y = 1.5
        col_exec.operator(OT_CreateAngleObjects.bl_idname, icon='MESH_UVSPHERE', text="Create 3 Spheres & 2 Arrows")

class PT_LinksPanel(Panel):
    bl_label = "Links"; bl_idname = f"{PREFIX}_PT_links"; bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME; bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context):
        for l in ADDON_LINKS: self.layout.operator(OT_OpenUrl.bl_idname, text=l["label"]).url = l["url"]

class PT_RemovePanel(Panel):
    bl_label = "System"; bl_idname = f"{PREFIX}_PT_remove"; bl_space_type = 'VIEW_3D'; bl_region_type = 'UI'; bl_category = TAB_NAME; bl_options = {'DEFAULT_CLOSED'}
    def draw(self, context): self.layout.operator(OT_RemoveAddon.bl_idname, icon='CANCEL', text="Remove Addon")

# ==============================================================================
#  REGISTER
# ==============================================================================

classes = (
    PG_SphereProps, OT_CreateAngleObjects, OT_CopyFullScript, OT_CopyAngleInfo, 
    OT_OpenUrl, OT_RemoveAddon, 
    PT_MainPanel, PT_LinksPanel, PT_RemovePanel
)

def auto_open_sidebar():
    try:
        for window in bpy.context.window_manager.windows:
            for area in window.screen.areas:
                if area.type == 'VIEW_3D':
                    for space in area.spaces:
                        if space.type == 'VIEW_3D':
                            if not space.show_region_ui:
                                space.show_region_ui = True
    except: pass
    return None

def register():
    for c in classes: bpy.utils.register_class(c)
    setattr(bpy.types.Scene, PROPS_NAME, PointerProperty(type=PG_SphereProps))
    bpy.app.timers.register(auto_open_sidebar, first_interval=0.1)

def unregister():
    global _timer
    if _timer is not None:
        try: bpy.app.timers.unregister(_timer)
        except: pass
        _timer = None

    cleanup_preview_data()

    if hasattr(bpy.types.Scene, PROPS_NAME): delattr(bpy.types.Scene, PROPS_NAME)
    for c in reversed(classes): bpy.utils.unregister_class(c)

if __name__ == "__main__": 
    register()