"""
GCP データパイプライン 3D構成図 ジェネレーター（Blender 4.2〜5.x）

使い方:
  1. Blender を開き、上部の「Scripting」タブを開く
  2. このファイルを開く（または全文を貼り付ける）→ ▶（Run Script）
  3. ビューポートで Z キー →「マテリアルプレビュー」または「レンダー」表示にして、スペースキーで再生

構成は下の SCENE（JSON）だけで決まる。ノード・接続・フロアを書き換えて再実行すれば、
別案件の構成図がそのまま作れる。
"""
import bpy, bmesh, json, math, os
from mathutils import Vector

SCENE = json.loads(r'''{
 "title": "小売チェーン データ基盤（GCP）3D構成図",
 "floors": [
  {
   "id": "F0",
   "z": 0,
   "name": "① データソース",
   "sub": "店舗・EC・アプリ・オンプレ・取引先"
  },
  {
   "id": "F1",
   "z": 4.5,
   "name": "② インジェスト",
   "sub": "受け口（秒単位 / 日次）"
  },
  {
   "id": "F2",
   "z": 9,
   "name": "③ 処理",
   "sub": "ストリーミング整形 / 大規模変換"
  },
  {
   "id": "F3",
   "z": 13.5,
   "name": "④ 蓄積",
   "sub": "raw → staging → mart"
  },
  {
   "id": "F4",
   "z": 18,
   "name": "⑤ 活用",
   "sub": "BI・ML・API・外部連携"
  }
 ],
 "lanes": [
  {
   "id": "rt",
   "name": "リアルタイム経路（秒単位）",
   "y": 5.625
  },
  {
   "id": "batch",
   "name": "バッチ経路（日次）",
   "y": -5.625
  }
 ],
 "nodes": [
  {
   "id": "POS",
   "label": "店舗POS",
   "svc": "Source",
   "floor": "F0",
   "x": -10.0,
   "y": 6.25,
   "kind": "source",
   "desc": "全国の店舗レジ。売上イベントを秒単位で送信。"
  },
  {
   "id": "EC",
   "label": "ECサイト",
   "svc": "Source",
   "floor": "F0",
   "x": -5.625,
   "y": 6.25,
   "kind": "source",
   "desc": "Webの行動・注文イベント。"
  },
  {
   "id": "APP",
   "label": "モバイルアプリ",
   "svc": "Source",
   "floor": "F0",
   "x": -1.25,
   "y": 7.5,
   "kind": "source",
   "desc": "アプリの利用ログ。"
  },
  {
   "id": "ONP",
   "label": "オンプレ基幹DB",
   "svc": "Source",
   "floor": "F0",
   "x": -10.0,
   "y": -6.25,
   "kind": "source",
   "desc": "商品・在庫マスタなど。Interconnect経由で連携。"
  },
  {
   "id": "SFTP",
   "label": "取引先SFTP",
   "svc": "Source",
   "floor": "F0",
   "x": -5.625,
   "y": -6.25,
   "kind": "source",
   "desc": "取引先から日次で届くファイル。"
  },
  {
   "id": "PS1",
   "label": "pos-events",
   "svc": "Pub/Sub",
   "floor": "F1",
   "x": -10.0,
   "y": 5.0,
   "kind": "ingest",
   "desc": "POSイベントの受け口。発注APIのイベントもここに戻ってくる。"
  },
  {
   "id": "PS2",
   "label": "web-events",
   "svc": "Pub/Sub",
   "floor": "F1",
   "x": -5.625,
   "y": 5.0,
   "kind": "ingest",
   "desc": "ECイベントの受け口。"
  },
  {
   "id": "FB",
   "label": "Firebase Analytics",
   "svc": "Firebase",
   "floor": "F1",
   "x": -1.25,
   "y": 6.875,
   "kind": "ingest",
   "desc": "アプリログをBigQueryへ直接エクスポート。"
  },
  {
   "id": "DS",
   "label": "Datastream",
   "svc": "CDC",
   "floor": "F1",
   "x": -10.0,
   "y": -5.0,
   "kind": "ingest",
   "desc": "基幹DBの変更差分（CDC）を取り込む。"
  },
  {
   "id": "CF",
   "label": "Cloud Run functions",
   "svc": "取込関数",
   "floor": "F1",
   "x": -5.625,
   "y": -5.0,
   "kind": "ingest",
   "desc": "SFTPのファイルを取得してGCSへ置く。"
  },
  {
   "id": "SCH",
   "label": "Cloud Scheduler",
   "svc": "起動",
   "floor": "F1",
   "x": -5.625,
   "y": -9.375,
   "kind": "ctrl",
   "desc": "取込関数を定時に起動。"
  },
  {
   "id": "DF1",
   "label": "Dataflow",
   "svc": "ストリーミング整形",
   "floor": "F2",
   "x": -6.25,
   "y": 4.375,
   "kind": "process",
   "desc": "イベントを整形し、BigQueryとBigtableへ書き込む。"
  },
  {
   "id": "DLQ",
   "label": "dead-letter",
   "svc": "Pub/Sub",
   "floor": "F2",
   "x": -1.25,
   "y": 8.75,
   "kind": "error",
   "desc": "整形できなかったエラー行の退避先。"
  },
  {
   "id": "GCS2",
   "label": "GCS: error",
   "svc": "Cloud Storage",
   "floor": "F2",
   "x": 3.125,
   "y": 8.75,
   "kind": "error",
   "desc": "エラー行の保管。"
  },
  {
   "id": "GCS1",
   "label": "GCS: landing",
   "svc": "Cloud Storage",
   "floor": "F2",
   "x": -7.5,
   "y": -4.375,
   "kind": "storage",
   "desc": "バッチデータの着地点。"
  },
  {
   "id": "DPROC",
   "label": "Dataproc",
   "svc": "大規模変換",
   "floor": "F2",
   "x": -1.875,
   "y": -5.625,
   "kind": "process",
   "desc": "Sparkによる大規模変換。"
  },
  {
   "id": "BT",
   "label": "Bigtable",
   "svc": "在庫リアルタイム",
   "floor": "F3",
   "x": -2.5,
   "y": 6.25,
   "kind": "storage",
   "desc": "店舗在庫をミリ秒で引けるように保持。"
  },
  {
   "id": "BQRAW",
   "label": "BigQuery raw",
   "svc": "生データ",
   "floor": "F3",
   "x": -1.25,
   "y": 0.0,
   "kind": "bq1",
   "desc": "取り込んだままの生データ。リアルタイムとバッチがここで合流。"
  },
  {
   "id": "DFORM",
   "label": "Dataform",
   "svc": "変換SQL",
   "floor": "F3",
   "x": 3.125,
   "y": -2.5,
   "kind": "process",
   "desc": "raw→staging→martの変換SQLを管理。"
  },
  {
   "id": "BQSTG",
   "label": "BigQuery staging",
   "svc": "整形済み",
   "floor": "F3",
   "x": 5.625,
   "y": 0.0,
   "kind": "bq2",
   "desc": "型・重複・名寄せを整えた層。"
  },
  {
   "id": "BQMART",
   "label": "BigQuery mart",
   "svc": "分析用",
   "floor": "F3",
   "x": 11.25,
   "y": 0.0,
   "kind": "bq3",
   "desc": "業務で使う集計済みテーブル。予測結果もここに戻る。"
  },
  {
   "id": "LOOK",
   "label": "Looker",
   "svc": "BI",
   "floor": "F4",
   "x": 8.75,
   "y": -6.25,
   "kind": "serve",
   "desc": "経営・店舗向けダッシュボード。"
  },
  {
   "id": "VAI",
   "label": "Vertex AI",
   "svc": "需要予測",
   "floor": "F4",
   "x": 13.75,
   "y": -1.875,
   "kind": "serve",
   "desc": "martのデータで需要予測モデルを学習・推論。"
  },
  {
   "id": "EP",
   "label": "Vertex AI Endpoint",
   "svc": "オンライン推論",
   "floor": "F4",
   "x": 13.75,
   "y": 5.0,
   "kind": "serve",
   "desc": "発注APIから呼ばれる推論エンドポイント。"
  },
  {
   "id": "RUN",
   "label": "発注API",
   "svc": "Cloud Run",
   "floor": "F4",
   "x": 6.25,
   "y": 6.875,
   "kind": "serve",
   "desc": "在庫と予測から自動発注。発注イベントはPub/Subへ戻る。"
  },
  {
   "id": "AH",
   "label": "Analytics Hub",
   "svc": "取引先共有",
   "floor": "F4",
   "x": 18.125,
   "y": -5.0,
   "kind": "serve",
   "desc": "取引先へデータを共有。"
  },
  {
   "id": "RE",
   "label": "Reverse ETL",
   "svc": "CRM連携",
   "floor": "F4",
   "x": 18.125,
   "y": 0.625,
   "kind": "serve",
   "desc": "martのデータをCRMへ書き戻す。"
  },
  {
   "id": "COMP",
   "label": "Cloud Composer",
   "svc": "指揮塔",
   "floor": "SKY",
   "x": -15.625,
   "y": -1.875,
   "z": 23.0,
   "kind": "ctrl",
   "desc": "バッチ処理全体のオーケストレーション。上空から各処理に指示を出す。"
  },
  {
   "id": "MON",
   "label": "Monitoring / Logging",
   "svc": "監視",
   "floor": "SKY",
   "x": -15.625,
   "y": 6.25,
   "z": 23.0,
   "kind": "mon",
   "desc": "Dataflow・Composer・発注APIを監視。"
  },
  {
   "id": "DPLEX",
   "label": "Dataplex",
   "svc": "カタログ/品質",
   "floor": "SKY",
   "x": 3.75,
   "y": -10.0,
   "z": 16.0,
   "kind": "gov",
   "desc": "蓄積フロアとlandingを常時スキャンして品質を監視。"
  }
 ],
 "edges": [
  {
   "from": "POS",
   "to": "PS1",
   "type": "rt"
  },
  {
   "from": "EC",
   "to": "PS2",
   "type": "rt"
  },
  {
   "from": "APP",
   "to": "FB",
   "type": "rt"
  },
  {
   "from": "FB",
   "to": "BQRAW",
   "type": "rt"
  },
  {
   "from": "PS1",
   "to": "DF1",
   "type": "rt"
  },
  {
   "from": "PS2",
   "to": "DF1",
   "type": "rt"
  },
  {
   "from": "DF1",
   "to": "BQRAW",
   "type": "rt"
  },
  {
   "from": "DF1",
   "to": "BT",
   "type": "rt"
  },
  {
   "from": "BT",
   "to": "RUN",
   "type": "rt"
  },
  {
   "from": "EP",
   "to": "RUN",
   "type": "rt"
  },
  {
   "from": "VAI",
   "to": "EP",
   "type": "rt"
  },
  {
   "from": "DF1",
   "to": "DLQ",
   "type": "err",
   "label": "エラー行"
  },
  {
   "from": "DLQ",
   "to": "GCS2",
   "type": "err"
  },
  {
   "from": "ONP",
   "to": "DS",
   "type": "batch",
   "label": "Interconnect"
  },
  {
   "from": "DS",
   "to": "GCS1",
   "type": "batch"
  },
  {
   "from": "SFTP",
   "to": "CF",
   "type": "batch"
  },
  {
   "from": "CF",
   "to": "GCS1",
   "type": "batch"
  },
  {
   "from": "GCS1",
   "to": "DPROC",
   "type": "batch"
  },
  {
   "from": "GCS1",
   "to": "BQRAW",
   "type": "batch"
  },
  {
   "from": "DPROC",
   "to": "BQRAW",
   "type": "batch"
  },
  {
   "from": "BQRAW",
   "to": "DFORM",
   "type": "batch"
  },
  {
   "from": "DFORM",
   "to": "BQSTG",
   "type": "batch"
  },
  {
   "from": "BQSTG",
   "to": "BQMART",
   "type": "batch"
  },
  {
   "from": "BQMART",
   "to": "LOOK",
   "type": "batch"
  },
  {
   "from": "BQMART",
   "to": "VAI",
   "type": "batch"
  },
  {
   "from": "BQMART",
   "to": "AH",
   "type": "batch"
  },
  {
   "from": "BQMART",
   "to": "RE",
   "type": "batch"
  },
  {
   "from": "VAI",
   "to": "BQMART",
   "type": "loop",
   "label": "予測結果",
   "turns": 1.0,
   "radius": 1.6
  },
  {
   "from": "RUN",
   "to": "PS1",
   "type": "loop",
   "label": "発注イベント",
   "turns": 1.5,
   "radius": 2.2
  },
  {
   "from": "COMP",
   "to": "DPROC",
   "type": "ctrl"
  },
  {
   "from": "COMP",
   "to": "DFORM",
   "type": "ctrl"
  },
  {
   "from": "COMP",
   "to": "DS",
   "type": "ctrl"
  },
  {
   "from": "SCH",
   "to": "CF",
   "type": "ctrl"
  },
  {
   "from": "MON",
   "to": "DF1",
   "type": "mon"
  },
  {
   "from": "MON",
   "to": "COMP",
   "type": "mon"
  },
  {
   "from": "MON",
   "to": "RUN",
   "type": "mon"
  },
  {
   "from": "DPLEX",
   "to": "BQRAW",
   "type": "gov"
  },
  {
   "from": "DPLEX",
   "to": "BQSTG",
   "type": "gov"
  },
  {
   "from": "DPLEX",
   "to": "BQMART",
   "type": "gov"
  },
  {
   "from": "DPLEX",
   "to": "GCS1",
   "type": "gov"
  }
 ],
 "flowStyles": {
  "rt": {
   "name": "リアルタイム",
   "color": "#3BA7FF",
   "period": 40,
   "count": 5,
   "clump": 1
  },
  "batch": {
   "name": "バッチ（日次）",
   "color": "#FF9A3B",
   "period": 120,
   "count": 1,
   "clump": 4
  },
  "loop": {
   "name": "戻りループ",
   "color": "#FF4FD8",
   "period": 80,
   "count": 3,
   "clump": 1
  },
  "err": {
   "name": "エラー退避",
   "color": "#FF4B4B",
   "period": 120,
   "count": 1,
   "clump": 1
  },
  "ctrl": {
   "name": "制御（Composer等）",
   "color": "#A08BFF",
   "period": 60,
   "count": 1,
   "clump": 1
  },
  "mon": {
   "name": "監視",
   "color": "#5EE7C8",
   "period": 120,
   "count": 1,
   "clump": 1
  },
  "gov": {
   "name": "ガバナンス（Dataplex）",
   "color": "#7CFF8A",
   "period": 120,
   "count": 1,
   "clump": 1
  }
 },
 "kindColors": {
  "source": "#8A93A6",
  "ingest": "#2E6FD8",
  "process": "#3A4FCB",
  "storage": "#2B8C9E",
  "bq1": "#3D6EA8",
  "bq2": "#3F86D6",
  "bq3": "#4FB0FF",
  "serve": "#2FA37A",
  "error": "#C2384A",
  "ctrl": "#6E58D8",
  "mon": "#2FA58F",
  "gov": "#3FAE4F"
 },
 "security": {
  "label": "VPC Service Controls / IAM / CMEK 境界",
  "min": [
   -13.125,
   -11.25,
   2.8
  ],
  "max": [
   20.625,
   11.25,
   25.5
  ]
 }
}''')

FPS = 24
FRAMES = 240          # 10秒でループ
SECONDS_LABEL = True

# ------------------------------------------------------------ utilities
def hex2rgb(h):
    h = h.lstrip('#')
    c = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]
    # sRGB -> linear
    return tuple(((v + 0.055) / 1.055) ** 2.4 if v > 0.04045 else v / 12.92 for v in c) + (1.0,)

def find_jp_font():
    cands = [
        os.environ.get('BPVIZ_FONT', ''),
        '/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc',
        '/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc',
        '/System/Library/Fonts/Hiragino Sans GB.ttc',
        'C:/Windows/Fonts/YuGothB.ttc', 'C:/Windows/Fonts/meiryo.ttc', 'C:/Windows/Fonts/msgothic.ttc',
        '/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc',
        '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
    ]
    for p in cands:
        if p and os.path.exists(p):
            try:
                return bpy.data.fonts.load(p, check_existing=True)
            except Exception:
                pass
    return None  # 日本語フォントが無い環境では既定フォント（日本語は□になる）

def link(obj, coll):
    coll.objects.link(obj)
    return obj

def new_coll(name, parent=None):
    c = bpy.data.collections.new(name)
    (parent or bpy.context.scene.collection).children.link(c)
    return c

def mat_nodes(name):
    m = bpy.data.materials.new(name)
    try:
        m.use_nodes = True
    except Exception:
        pass
    for attr, val in (('surface_render_method', 'BLENDED'), ('blend_method', 'BLEND')):
        try:
            setattr(m, attr, val)
        except Exception:
            pass
    nt = m.node_tree
    for n in list(nt.nodes):
        nt.nodes.remove(n)
    out = nt.nodes.new('ShaderNodeOutputMaterial')
    return m, nt, out

def mat_emission(name, color, strength=5.0, alpha=1.0):
    m, nt, out = mat_nodes(name)
    em = nt.nodes.new('ShaderNodeEmission')
    em.inputs['Color'].default_value = hex2rgb(color)
    em.inputs['Strength'].default_value = strength
    if alpha >= 1.0:
        nt.links.new(em.outputs[0], out.inputs['Surface'])
    else:
        tr = nt.nodes.new('ShaderNodeBsdfTransparent')
        mix = nt.nodes.new('ShaderNodeMixShader')
        mix.inputs[0].default_value = alpha
        nt.links.new(tr.outputs[0], mix.inputs[1])
        nt.links.new(em.outputs[0], mix.inputs[2])
        nt.links.new(mix.outputs[0], out.inputs['Surface'])
    m.diffuse_color = hex2rgb(color)
    return m

def mat_node_box(name, color):
    m, nt, out = mat_nodes(name)
    p = nt.nodes.new('ShaderNodeBsdfPrincipled')
    p.inputs['Base Color'].default_value = hex2rgb(color)
    p.inputs['Roughness'].default_value = 0.35
    for key in ('Emission Color', 'Emission'):
        if key in p.inputs:
            p.inputs[key].default_value = hex2rgb(color)
            break
    p.inputs['Emission Strength'].default_value = 0.9
    nt.links.new(p.outputs[0], out.inputs['Surface'])
    m.diffuse_color = hex2rgb(color)
    return m

def mat_glass_shell(name, color, edge_strength=1.5, face_alpha=0.04):
    """縁だけ光る半透明の殻（セキュリティ境界・フロア板用）"""
    m, nt, out = mat_nodes(name)
    lw = nt.nodes.new('ShaderNodeLayerWeight')
    lw.inputs['Blend'].default_value = 0.25
    ramp = nt.nodes.new('ShaderNodeMath'); ramp.operation = 'MULTIPLY_ADD'
    ramp.inputs[1].default_value = 0.5
    ramp.inputs[2].default_value = face_alpha
    nt.links.new(lw.outputs['Facing'], ramp.inputs[0])
    tr = nt.nodes.new('ShaderNodeBsdfTransparent')
    em = nt.nodes.new('ShaderNodeEmission')
    em.inputs['Color'].default_value = hex2rgb(color)
    em.inputs['Strength'].default_value = edge_strength
    mix = nt.nodes.new('ShaderNodeMixShader')
    nt.links.new(ramp.outputs[0], mix.inputs[0])
    nt.links.new(tr.outputs[0], mix.inputs[1])
    nt.links.new(em.outputs[0], mix.inputs[2])
    nt.links.new(mix.outputs[0], out.inputs['Surface'])
    m.diffuse_color = hex2rgb(color)[:3] + (0.15,)
    return m

def mesh_box(name, size, bevel=0.0):
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    bmesh.ops.create_cube(bm, size=1.0)
    bmesh.ops.scale(bm, vec=Vector(size), verts=bm.verts)
    if bevel > 0:
        bmesh.ops.bevel(bm, geom=list(bm.edges), offset=bevel, segments=3, affect='EDGES', profile=0.5)
    bm.to_mesh(me); bm.free()
    for p in me.polygons:
        p.use_smooth = True
    return me

def mesh_sphere(name, r, seg=16):
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    bmesh.ops.create_uvsphere(bm, u_segments=seg, v_segments=seg // 2, radius=r)
    bm.to_mesh(me); bm.free()
    for p in me.polygons:
        p.use_smooth = True
    return me

def mesh_cylinder(name, r, depth, seg=24):
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    bmesh.ops.create_cone(bm, cap_ends=True, segments=seg, radius1=r, radius2=r, depth=depth)
    bm.to_mesh(me); bm.free()
    for p in me.polygons:
        p.use_smooth = True
    return me

def mesh_torus(name, R, r, seg=48, rs=8):
    me = bpy.data.meshes.new(name)
    verts, faces = [], []
    for i in range(seg):
        a = 2 * math.pi * i / seg
        for j in range(rs):
            b = 2 * math.pi * j / rs
            verts.append(((R + r * math.cos(b)) * math.cos(a), (R + r * math.cos(b)) * math.sin(a), r * math.sin(b)))
    for i in range(seg):
        for j in range(rs):
            a, b = i * rs + j, ((i + 1) % seg) * rs + j
            c, d = ((i + 1) % seg) * rs + (j + 1) % rs, i * rs + (j + 1) % rs
            faces.append((a, b, c, d))
    me.from_pydata(verts, [], faces)
    return me

def poly_curve(name, pts, bevel=0.0):
    cu = bpy.data.curves.new(name, 'CURVE')
    cu.dimensions = '3D'
    cu.use_path = True
    cu.bevel_depth = bevel
    cu.bevel_resolution = 2
    sp = cu.splines.new('POLY')
    sp.points.add(len(pts) - 1)
    for p, v in zip(sp.points, pts):
        p.co = (v[0], v[1], v[2], 1.0)
    return cu

def add_text(name, body, size, font, mat, coll, loc, cam, align='CENTER'):
    cu = bpy.data.curves.new(name, 'FONT')
    cu.body = body
    cu.size = size
    cu.align_x = align
    cu.align_y = 'CENTER'
    cu.extrude = 0.0
    if font:
        cu.font = font
    ob = link(bpy.data.objects.new(name, cu), coll)
    ob.location = loc
    ob.data.materials.append(mat)
    c = ob.constraints.new('TRACK_TO')       # 常にカメラを向く
    c.target = cam
    c.track_axis = 'TRACK_Z'
    c.up_axis = 'UP_Y'
    return ob

def add_driver(owner, path, expr, index=-1):
    fc = owner.driver_add(path, index) if index >= 0 else owner.driver_add(path)
    fc.driver.type = 'SCRIPTED'
    fc.driver.expression = expr     # 「frame」だけを使う単純式なので、Python自動実行なしで動く
    return fc

# ------------------------------------------------------------ geometry (Web版と同じロジック)
def node_pos(n, floors):
    if 'z' in n:
        return Vector((n['x'], n['y'], n['z']))
    return Vector((n['x'], n['y'], floors[n['floor']]['z'] + 0.45))

def bezier(p0, p1, p2, p3, t):
    u = 1 - t
    return p0 * (u ** 3) + p1 * (3 * u * u * t) + p2 * (3 * u * t * t) + p3 * (t ** 3)

def edge_points(e, a, b, steps=64):
    if e['type'] == 'loop':          # 上の階から下の階へ戻る流れは螺旋で見せる
        turns, rad = e.get('turns', 1.0), e.get('radius', 1.8)
        pts = []
        for i in range(steps + 1):
            t = i / steps
            base = a.lerp(b, t)
            ang = 2 * math.pi * turns * t
            env = math.sin(math.pi * t)
            pts.append(Vector((base.x + rad * env * math.cos(ang), base.y + rad * env * math.sin(ang), base.z)))
        return pts
    dz = b.z - a.z
    if abs(dz) > 0.01:
        h1 = a + Vector((0, 0, dz * 0.55))
        h2 = b - Vector((0, 0, dz * 0.55))
    else:
        d = b - a
        h1 = a + d / 3 + Vector((0, 0, 1.3))
        h2 = b - d / 3 + Vector((0, 0, 1.3))
    return [bezier(a, h1, h2, b, i / steps) for i in range(steps + 1)]

# ------------------------------------------------------------ build
def build():
    S = SCENE
    sc = bpy.context.scene
    # 既存の生成物を消す
    for ob in list(bpy.data.objects):
        bpy.data.objects.remove(ob, do_unlink=True)
    for c in list(bpy.data.collections):
        bpy.data.collections.remove(c)
    for blk in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights):
        for d in list(blk):
            if d.users == 0:
                blk.remove(d)

    sc.frame_start, sc.frame_end = 1, FRAMES
    sc.render.fps = FPS
    font = find_jp_font()
    floors = {f['id']: f for f in S['floors']}
    nodes = {n['id']: n for n in S['nodes']}
    styles = S['flowStyles']
    kind_col = S['kindColors']

    world = sc.world or bpy.data.worlds.new('World')
    sc.world = world
    try:
        world.use_nodes = True
    except Exception:
        pass
    bg = world.node_tree.nodes.get('Background')
    if bg:
        bg.inputs[0].default_value = hex2rgb('#060A14')
        bg.inputs[1].default_value = 1.0

    C_cam = new_coll('00_Camera')
    C_floor = new_coll('01_Floors')
    C_node = new_coll('02_Nodes')
    C_label = new_coll('03_Labels')
    C_pipe = new_coll('04_Pipes')
    C_flow = new_coll('05_Flows')
    C_cross = new_coll('06_CrossCutting')

    # camera rig: 中心の Empty を回すとカメラが周回する
    center = Vector((3.75, 0.0, 12.5))
    pivot = link(bpy.data.objects.new('CamPivot', None), C_cam)
    pivot.location = center
    target = link(bpy.data.objects.new('CamTarget', None), C_cam)
    target.location = center
    cam_data = bpy.data.cameras.new('Camera')
    cam_data.lens = 29
    cam_data.clip_end = 500
    cam = link(bpy.data.objects.new('Camera', cam_data), C_cam)
    cam.parent = pivot
    cam.location = (0.0, -50.0, 18.0)
    tt = cam.constraints.new('TRACK_TO'); tt.target = target; tt.track_axis = 'TRACK_NEGATIVE_Z'; tt.up_axis = 'UP_Y'
    sc.camera = cam
    add_driver(pivot, 'rotation_euler', '0.25 + 0.45*sin(2*pi*frame/%d)' % FRAMES, 2)   # 左右にゆっくり首振り
    pivot.rotation_euler[0] = 0.0

    sun = bpy.data.lights.new('Key', 'SUN'); sun.energy = 1.2; sun.angle = 0.3
    so = link(bpy.data.objects.new('Key', sun), C_cam); so.rotation_euler = (math.radians(50), 0, math.radians(30))

    m_text = mat_emission('M_Text', '#E8EEF9', 2.5)
    m_text_dim = mat_emission('M_TextDim', '#8FA3C6', 1.6)

    # floors
    fx0, fx1, fy0, fy1 = -12.5, 20.0, -10.6, 10.6
    m_plate = mat_emission('M_Floor', '#2A4D8F', 0.8, 0.10)
    m_frame = mat_emission('M_FloorFrame', '#4A78C9', 1.8)
    for f in S['floors']:
        z = f['z']
        pl = link(bpy.data.objects.new('Floor_' + f['id'], mesh_box('FloorMesh_' + f['id'], (fx1 - fx0, fy1 - fy0, 0.06))), C_floor)
        pl.location = ((fx0 + fx1) / 2, (fy0 + fy1) / 2, z)
        pl.data.materials.append(m_plate)
        fr = poly_curve('FloorFrame_' + f['id'], [(fx0, fy0, z), (fx1, fy0, z), (fx1, fy1, z), (fx0, fy1, z), (fx0, fy0, z)], 0.035)
        fo = link(bpy.data.objects.new('FloorFrame_' + f['id'], fr), C_floor); fo.data.materials.append(m_frame)
        add_text('FloorLabel_' + f['id'], f['name'], 2.4, font, m_text, C_label, (fx0 - 0.6, fy0 - 0.3, z + 0.5), cam, 'RIGHT')
        add_text('FloorSub_' + f['id'], f['sub'], 1.15, font, m_text_dim, C_label, (fx0 - 0.6, fy0 - 0.3, z - 1.0), cam, 'RIGHT')
    # lane labels
    for ln in S['lanes']:
        col = styles[ln['id']]['color']
        add_text('Lane_' + ln['id'], ln['name'], 1.1, font, mat_emission('M_Lane_' + ln['id'], col, 3.0), C_label,
                 (fx1 + 0.8, ln['y'], floors['F2']['z'] + 0.6), cam, 'LEFT')

    # nodes
    pos = {k: node_pos(n, floors) for k, n in nodes.items()}
    kind_mats = {k: mat_node_box('M_Kind_' + k, c) for k, c in kind_col.items()}
    for n in S['nodes']:
        size = (2.1, 2.1, 0.95) if n['kind'] not in ('source',) else (2.1, 2.1, 0.7)
        if n['id'] in ('BQRAW', 'BQSTG', 'BQMART'):
            size = (3.0, 3.0, 1.1)
        ob = link(bpy.data.objects.new('Node_' + n['id'], mesh_box('NodeMesh_' + n['id'], size, 0.18)), C_node)
        ob.location = pos[n['id']]
        ob.data.materials.append(kind_mats[n['kind']])
        body = n['label'] if n['kind'] == 'source' else n['label'] + '\n' + n['svc']
        add_text('Label_' + n['id'], body, 1.5, font, m_text, C_label, pos[n['id']] + Vector((0, 0, 2.7)), cam)

    # towers for sky nodes (Composer / Monitoring)
    for nid, col in (('COMP', '#A08BFF'), ('MON', '#5EE7C8')):
        p = pos[nid]
        tw = link(bpy.data.objects.new('Tower_' + nid, mesh_cylinder('TowerMesh_' + nid, 0.12, p.z)), C_cross)
        tw.location = (p.x, p.y, p.z / 2)
        tw.data.materials.append(mat_emission('M_Tower_' + nid, col, 1.2, 0.35))
        bc = link(bpy.data.objects.new('Beacon_' + nid, mesh_sphere('BeaconMesh_' + nid, 0.6)), C_cross)
        bc.location = p + Vector((0, 0, 0.9))
        bc.data.materials.append(mat_emission('M_Beacon_' + nid, col, 10.0))
        add_driver(bc, 'scale', '1 + 0.35*sin(2*pi*frame/24)', 0)
        add_driver(bc, 'scale', '1 + 0.35*sin(2*pi*frame/24)', 1)
        add_driver(bc, 'scale', '1 + 0.35*sin(2*pi*frame/24)', 2)

    # pipes + flowing particles
    pipe_mats = {k: mat_emission('M_Pipe_' + k, s['color'], 1.4, 0.45) for k, s in styles.items()}
    dot_mats = {k: mat_emission('M_Dot_' + k, s['color'], 14.0) for k, s in styles.items()}
    dot_mesh = {k: mesh_sphere('Dot_' + k, 0.28 if k == 'batch' else 0.21) for k in styles}
    for i, e in enumerate(S['edges']):
        a, b = pos[e['from']], pos[e['to']]
        pts = edge_points(e, a, b)
        st = styles[e['type']]
        thin = e['type'] in ('ctrl', 'mon', 'gov')
        cu = poly_curve('Pipe_%02d_%s_%s' % (i, e['from'], e['to']), pts, 0.035 if thin else 0.075)
        po = link(bpy.data.objects.new(cu.name, cu), C_pipe)
        po.data.materials.append(pipe_mats[e['type']])
        if e.get('label'):
            mid = pts[len(pts) // 2]
            add_text('EdgeLabel_%02d' % i, e['label'], 1.3, font,
                     mat_emission('M_EL_%02d' % i, st['color'], 3.0), C_label, mid + Vector((0, 0, 0.6)), cam)
        T = st['period']
        for k in range(st['count']):
            for c in range(st['clump']):
                phase = k / st['count'] + c * 0.035 + (i * 0.137) % 1.0 * (0 if st['count'] > 1 else 1)
                d = link(bpy.data.objects.new('Flow_%02d_%d_%d' % (i, k, c), dot_mesh[e['type']]), C_flow)
                d.data.materials.append(dot_mats[e['type']])
                fp = d.constraints.new('FOLLOW_PATH')
                fp.target = po
                fp.use_fixed_location = True
                expr = '(frame/%d + %.4f) - floor(frame/%d + %.4f)' % (T, phase, T, phase)
                add_driver(fp, 'offset_factor', expr)

    # Dataplex: 蓄積フロアをなめるスキャン光
    zs = floors['F3']['z']
    scan = link(bpy.data.objects.new('DataplexScan', mesh_box('ScanMesh', (0.25, fy1 - fy0, 1.6))), C_cross)
    scan.location = (0, 0, zs + 0.8)
    scan.data.materials.append(mat_emission('M_Scan', '#7CFF8A', 2.0, 0.12))
    add_driver(scan, 'location', '-5 + 22.5*((frame/120) - floor(frame/120))', 0)

    # Monitoring: 監視対象にパルスするリング
    m_ring = mat_emission('M_MonRing', '#5EE7C8', 4.0, 0.8)
    for nid in ('DF1', 'COMP', 'RUN'):
        rg = link(bpy.data.objects.new('MonRing_' + nid, mesh_torus('Ring_' + nid, 1.7, 0.05)), C_cross)
        rg.location = pos[nid] - Vector((0, 0, 0.3))
        rg.data.materials.append(m_ring)
        for ax in range(3):
            add_driver(rg, 'scale', '0.8 + 0.6*((frame/48) - floor(frame/48))', ax)

    # VPC-SC / IAM / CMEK: 全体を包む境界
    s0, s1 = Vector(S['security']['min']), Vector(S['security']['max'])
    sh = link(bpy.data.objects.new('SecurityPerimeter', mesh_box('SecMesh', tuple(s1 - s0), 0.0)), C_cross)
    sh.location = (s0 + s1) / 2
    sh.data.materials.append(mat_emission('M_SecurityFace', '#7FD7FF', 0.6, 0.025))
    m_secedge = mat_emission('M_SecurityEdge', '#7FD7FF', 3.0, 0.9)
    X = (s0.x, s1.x); Y = (s0.y, s1.y); Zs = (s0.z, s1.z)
    corners = [Vector((x, y, z)) for x in X for y in Y for z in Zs]
    for ia in range(8):
        for ib in range(ia + 1, 8):
            if sum(1 for k in range(3) if abs(corners[ia][k] - corners[ib][k]) > 1e-6) == 1:
                ec = poly_curve('SecEdge_%d_%d' % (ia, ib), [corners[ia], corners[ib]], 0.04)
                eo = link(bpy.data.objects.new(ec.name, ec), C_cross); eo.data.materials.append(m_secedge)
    add_text('SecurityLabel', S['security']['label'], 1.2, font, mat_emission('M_SecText', '#7FD7FF', 3.0), C_label,
             (s1.x - 6.0, s0.y, s0.z - 1.2), cam)
    add_text('Title', S['title'], 1.8, font, m_text, C_label, (2.5, 0, s1.z + 2.6), cam)

    # render settings
    sc.render.engine = 'CYCLES'
    try:
        sc.cycles.samples = 64
        sc.cycles.use_denoising = True
        sc.cycles.max_bounces = 4
        sc.cycles.transparent_max_bounces = 24
    except Exception:
        pass
    sc.render.resolution_x, sc.render.resolution_y = 1920, 1080
    try:
        sc.view_settings.view_transform = 'AgX'
        sc.view_settings.look = 'AgX - Punchy'
    except Exception:
        pass
    setup_glow(sc)
    sc.frame_set(1)
    return sc

def setup_glow(sc):
    """コンポジットでグロー（Blender 5.x / 4.x 両対応、失敗しても続行）"""
    try:
        if hasattr(sc, 'compositing_node_group'):           # Blender 5.x
            tree = bpy.data.node_groups.new('BPVIZ_Glow', 'CompositorNodeTree')
            tree.interface.new_socket('Image', in_out='OUTPUT', socket_type='NodeSocketColor')
            sc.compositing_node_group = tree
            rl = tree.nodes.new('CompositorNodeRLayers')
            gl = tree.nodes.new('CompositorNodeGlare')
            out = tree.nodes.new('NodeGroupOutput')
            for key, val in (('Type', 'Fog Glow'), ('Quality', 'Medium'), ('Size', 0.6), ('Strength', 0.6), ('Threshold', 0.8)):
                if key in gl.inputs:
                    try:
                        gl.inputs[key].default_value = val
                    except Exception:
                        pass
            tree.links.new(rl.outputs['Image'], gl.inputs['Image'])
            tree.links.new(gl.outputs['Image'], out.inputs[0])
        else:                                                 # Blender 4.x
            sc.use_nodes = True
            tree = sc.node_tree
            for n in list(tree.nodes):
                tree.nodes.remove(n)
            rl = tree.nodes.new('CompositorNodeRLayers')
            gl = tree.nodes.new('CompositorNodeGlare')
            gl.glare_type = 'FOG_GLOW'; gl.quality = 'MEDIUM'; gl.size = 7; gl.threshold = 0.8
            co = tree.nodes.new('CompositorNodeComposite')
            tree.links.new(rl.outputs['Image'], gl.inputs['Image'])
            tree.links.new(gl.outputs['Image'], co.inputs['Image'])
    except Exception as ex:
        print('glow setup skipped:', ex)

build()
print('BPVIZ: scene built')
