# -*- coding: utf-8 -*-
"""
VP-OLS Screening for Blender  v0.8.6
==================================
v0.8.6 の変更点
----------------
1. PLATEAU CityGML取得をモーダル処理化し、建築物/DEMのダウンロード進捗をUI表示。
2. 全体進捗、現在フェーズ、ファイル番号、MB進捗、解析件数、キャンセルを追加。
3. VP-OLS UIを実作業順（HTML読込→FATO座標→制限表面→PLATEAU→照査→断面）へ再編。
4. 各パネルに操作目的・次に行う操作の説明を追加し、初見でも上から順に操作可能にした。
5. v0.8.5までの曲線左右補正、PLATEAU建築物/DEM、距離LOD、交換、照査、断面機能を維持。

v0.8.5 の変更点
----------------
1. HTML→Blender交換時の曲線区間 left/right の見た目反転を修正。
   交換データの curveSignFactor を用い、ラベルは維持したまま曲率符号だけ補正する。
2. Blender単独で作成したサイトは従来どおり left=+1/R / right=-1/R を維持。

バーティポート制限表面(OLS)の生成 / 3D都市モデルとの抵触抽出 / 垂直離隔照査

v0.8.4 の変更点
---------------
1. PLATEAU地形モデル(dem)のAPI検索・CityGMLダウンロード・TIN Mesh化を追加。
2. DEMは都市モデルと分離した専用Collectionへ投入し、terrain_collへ自動設定可能。
3. HTML交換JSONの各site.geographic / plateauReferenceからPLATEAU取得座標を自動反映。
4. Blender→HTML交換にも各サイトの緯度経度を保持し、往復時のPLATEAU座標を維持。
5. v0.8.3までの建築物距離LOD、キャッシュ権限対策、HTML交換、照査・断面機能を維持。

v0.8.1 の変更点
---------------
1. PLATEAU CityGMLを全読込ET.parseから逐次iterparseへ変更し、大容量GMLのメモリ負荷を低減。
2. API接続テスト、HTTPエラー本文、検索URL、ダウンロード/解析診断を追加。
3. 0件取込時に「距離外・LODなし・形状なし・座標不一致」を原因別表示。
4. 容量上限で全ファイルが除外された場合を明示。

v0.8.0 の変更点
---------------
1. PLATEAU距離ベースLOD版とHTML交換版を単一アドオンへ統合。
2. Scene.vpols の競合を解消する統合スキーマ (PLATEAU + HTML交換) を採用。
3. 旧版PointerPropertyが残っている場合のPanelクラッシュを防止。
4. 登録時に旧 Scene.vpols を破棄し、統合 VPOLS_Props で再構築。

v0.7 の変更点
-------------
1. PLATEAU 配信サービス REST API 連携を追加。アクティブサイトの緯度・経度と
   指定半径から CityGML (bldg) を検索し、選択したファイルを直接ダウンロード可能。
2. 距離ベース LOD を追加。サイト中心からの距離帯を任意個数設定し、各帯ごとに
   LOD1〜LOD4 / 読み込まない を選択できる。建物単位で中心距離を判定する。
3. CityGML の gml:Polygon / xlink 参照を解析し、PLATEAU 建築物を Blender Mesh 化。
   JGD2011 緯度経度を GRS80 楕円体の局所 EN 座標へ変換し、Z は標高を保持する。
4. PLATEAU 建物をサイト原点 Empty の子として配置。サイト移動に都市モデルも追従。
5. API キャッシュ、ダウンロード容量制限、最大建物数、ローカル CityGML 読込を追加。

v0.6 の変更点
-------------
1. マルチサイト対応。制限表面を複数設置し、それぞれ独立に諸元を微調整できる。
   サイトはリストで管理し、「+」で3Dカーソル位置に追加、複製・削除も可能。
2. 各サイトは「原点Empty」(VP原点_xxx) を持ち、全OLSオブジェクトはその子として
   ローカル座標で生成される。原点Emptyを掴んで動かせば表面一式が追従し、
   再生成しても位置が保持される (従来はワールド原点へ戻るバグがあった)。
   原点は 3Dカーソル取込・数値編集・Empty直接移動 の3通りで設定できる。
3. 照査・断面は全サイトのOLS面を統合して扱う。重なる位置は最も低い面が
   支配面となる (複合制限表面の規約が自動的に成立)。
4. 縦断・横断はアクティブサイトの中心線を基準にする。

v0.5.1 の変更点 (レビュー修正)
------------------------------
1. 地表面 (地形DEM) を断面へ統合。
   - 地形コレクションを指定すると、断面に地表面ライン (緑) と地盤の塗り (土色) を描画
   - 建物底面が無い場合の塗り底面を地表面高で閉じられる (floor_mode=TERRAIN)
   - モデルが無い測点の離隔寸法は地表面に対して算定
   - CSV に地表面高・対地高さ列を追加
2. 建物断面のうち制限表面より上に出た部分を赤で塗り分け (split_over)。
   曲線区間を含め、どの建物のどの高さ帯が障害物になるかが断面上で直読できる。
3. カットアウェイの境界判定を修正:
   - 折れ線頂点近傍での符号揺れ (凹角部で見え消し側が反転する) を、
     射影内部優先 + 角の二等分方向による判定で解消
   - 長い線分が小さな建物のバウンディングボックスを貫くケースを
     線分-AABB交差 (slab法) で検出 (従来は頂点内包のみで、簡略化後の
     長い線分が建物を素通りして誤って全表示/全非表示になり得た)
4. 地形もカットアウェイ対象に含めた (都市モデル側と同じ扱い)。

v0.5 の変更点
-------------
0. 曲線進入面を含む断面カットアウェイ表示を追加。
   現在の縦断・横断・任意・手描き断面ラインを境界として、都市モデルとOLSの
   視点側（または左右指定側）を一時的に非表示化する。曲線ラインは折れ線近似し、
   境界を跨ぐメッシュだけ一時複製して三角形単位でクリップするため、元モデルは変更しない。
1. 3Dビュー上でクリックして断面ラインを作る「手描き断面」を追加。
   左クリックで点追加、Enter/右クリックで確定、Backspaceで1点戻す、Escで取消。
2. 切断ラインと垂直ガイド面を表示し、制限表面と周辺3D都市モデルの位置関係を
   3Dビュー上で確認しやすくした。

v0.4 の変更点
-------------
0. 断面機能 (縦断 / 横断 / 任意) を追加。
   制限表面と3D都市モデルを同一の切断線で切り、都市モデル側は塗りつぶし面
   として描画する。任意スパンで垂直離隔の寸法線と数値を記入し、CSV へも出力。
   配置は「現地建て(3Dモデルに重ねる)」と「展開図(縦倍率つき)」の2通り。

v0.3 の変更点
-------------
0. 抵触の判定単位をオブジェクトから「突出領域」へ変更。
   制限表面から突出した部分を点間距離による連結成分で分離し、同一オブジェクト
   でも突出位置が離れていれば別々の抵触箇所として計上する。
   屋上面を格子サンプリングすることで、突出範囲を面的に捉える。

v0.2 の変更点
-------------
1. 着陸帯 (FATO + 安全区域) の算定を整備指針の記述どおりに明示化。
   D ≥ 12m : 着陸帯径 = 2D          (SA余裕 = 0.25D)
   D <  12m: 着陸帯径 = 1.5D + 6m   (SA余裕 = 3m 固定)
   進入表面・転移表面・PSS は全て「着陸帯縁」から派生する。
2. 曲線区間を区間テーブル方式 (最大6区間) で設定可能に。
   v0.1 は「初期直線長」の既定値が全長と同値だったため曲線区間の割当が
   常に 0 になっていた (曲線が描けない原因)。
3. リアルタイム更新。数値を変えた瞬間に進入表面・転移表面を再生成する。
   転移表面は進入表面と同一の中心線から生成されるため、曲線に追従する。

Blender 4.0 以降を想定。
"""

bl_info = {
    "name": "VP-OLS Screening (Vertiport Obstacle Limitation Surfaces)",
    "author": "SkyOps & Security Lab",
    "version": (0, 8, 7),
    "blender": (4, 0, 0),
    "location": "View3D > サイドバー(N) > VP-OLS",
    "description": "バーティポート制限表面、PLATEAU距離LOD取込、3D都市モデル抵触抽出、垂直離隔照査",
    "category": "3D View",
}

import bpy
import bmesh
import math
import os
import csv
import json
import hashlib
import tempfile
import urllib.request
import urllib.parse
import urllib.error
import threading
import time
import xml.etree.ElementTree as ET
from mathutils import Vector
from mathutils.bvhtree import BVHTree
from bpy.props import (
    StringProperty, FloatProperty, IntProperty, BoolProperty,
    EnumProperty, PointerProperty, CollectionProperty,
)
from bpy.types import PropertyGroup, Operator, Panel, UIList
from bpy_extras.io_utils import ImportHelper, ExportHelper
from bpy_extras import view3d_utils


# Windowsでも確実に書き込み可能なPLATEAUキャッシュ既定値。
# LOCALAPPDATAが無い環境ではOS一時ディレクトリへフォールバックする。
VPOLS_DEFAULT_CACHE_DIR = os.path.join(
    os.environ.get("LOCALAPPDATA") or tempfile.gettempdir(),
    "VP_OLS", "PLATEAU_cache"
)


# =============================================================================
# SECTION 1 : 規格テーブル
# =============================================================================
#   safetyOffset : 安全区域の最小幅 [m]     (指針: 3m)
#   safetyFactor : 安全区域の D 比例係数    (指針: 0.25D)
#   → SA余裕 = max(safetyOffset, D * safetyFactor)
#   → 両者が入れ替わる D = safetyOffset / safetyFactor = 12m が分岐点
# -----------------------------------------------------------------------------

STANDARDS = {
    'mlit_vertiport': {
        'name': '国交省 バーティポート整備指針',
        'info': '着陸帯 2D (D≥12m) / 進入面 1220m・1:8・末端152.5m / 拡幅 昼10%(max7D)・夜15%(max10D)',
        'fatoFactor': 1.5, 'tlofFactorGround': 0.83, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.25,
        'appr': {'slope': 0.125, 'innerEdgeAtSafetyArea': True,
                 'approachLength': 1220.0, 'approachEndHeight': 152.5,
                 'sec1Divergence': 0.10, 'sec1DivergenceNight': 0.15,
                 'sec1Length': 0.0, 'sec2Length': 0.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 7.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 45.0},
        'inner': {'radius': 0.0, 'height': 0.0},
        'minCurveR': 270.0,
    },
    'icao_heli': {
        'name': 'ICAO Annex 14 Vol.II (ヘリポート)',
        'info': '進入面 8% / 着陸帯外周→転移面 1:2 / 内側水平面 半径300m',
        'fatoFactor': 1.5, 'tlofFactorGround': 0.83, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.25,
        'appr': {'slope': 0.08, 'innerEdgeAtSafetyArea': True,
                 'sec1Length': 245.0, 'sec1Divergence': 0.10,
                 'sec2Length': 1220.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 7.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 45.0},
        'inner': {'radius': 300.0, 'height': 45.0},
        'minCurveR': 270.0,
    },
    'faa_105a': {
        'name': 'FAA EB-105A (Vertiport Design)',
        'info': 'FATO=1.5D / TLOF=0.83D / Approach 1:8',
        'fatoFactor': 1.5, 'tlofFactorGround': 0.83, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.25,
        'appr': {'slope': 0.125, 'innerEdgeAtSafetyArea': True,
                 'sec1Length': 300.0, 'sec1Divergence': 0.10,
                 'sec2Length': 1000.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 7.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 40.0},
        'inner': {'radius': 0.0, 'height': 0.0},
        'minCurveR': 270.0,
    },
    'easa_vtol': {
        'name': 'EASA PTS-VPT-DSN (eVTOL)',
        'info': 'FATO=2D / OFV 全方位 / 転移面 1:2',
        'fatoFactor': 2.0, 'tlofFactorGround': 1.0, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.5,
        'appr': {'slope': 0.125, 'innerEdgeAtSafetyArea': True,
                 'sec1Length': 200.0, 'sec1Divergence': 0.15,
                 'sec2Length': 800.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 8.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 45.0},
        'inner': {'radius': 0.0, 'height': 0.0},
        'minCurveR': 270.0,
    },
    'icao_pins': {
        'name': 'ICAO PinS ヘリポート',
        'info': '進入面 1:8 / 着陸帯外周→1:2 転移面',
        'fatoFactor': 1.0, 'tlofFactorGround': 0.83, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.25,
        'appr': {'slope': 0.125, 'innerEdgeAtSafetyArea': True,
                 'sec1Length': 200.0, 'sec1Divergence': 0.10,
                 'sec2Length': 1000.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 7.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 45.0},
        'inner': {'radius': 0.0, 'height': 0.0},
        'minCurveR': 270.0,
    },
    'generic': {
        'name': '汎用 (カスタム)',
        'info': '進入勾配・転移面高を手動入力',
        'fatoFactor': 1.5, 'tlofFactorGround': 0.83, 'tlofFactorElevated': 1.0,
        'safetyOffset': 3.0, 'safetyFactor': 0.25,
        'appr': {'slope': 0.125, 'innerEdgeAtSafetyArea': True,
                 'sec1Length': 300.0, 'sec1Divergence': 0.10,
                 'sec2Length': 1000.0, 'sec2Divergence': 0.0,
                 'sec3Length': 0.0, 'sec3Divergence': 0.0,
                 'maxWidthFactorDay': 7.0, 'maxWidthFactorNight': 10.0},
        'trans': {'slope': 0.5, 'height': 45.0},
        'inner': {'radius': 0.0, 'height': 0.0},
        'minCurveR': 270.0,
    },
}

STANDARD_ITEMS = [(k, v['name'], v['info']) for k, v in STANDARDS.items()]

SURFACE_COLORS = {
    'approach':   (0.05, 0.65, 0.91, 0.30),
    'transition': (0.51, 0.55, 0.97, 0.30),
    'pss':        (0.65, 0.55, 0.98, 0.30),
    'inner':      (0.65, 0.55, 0.98, 0.22),
    'fato':       (0.05, 0.65, 0.91, 0.85),
    'tlof':       (0.98, 0.75, 0.14, 0.85),
    'safety':     (0.13, 0.83, 0.93, 0.55),
    'centerline': (1.00, 0.45, 0.20, 1.00),
    'conflict':   (0.95, 0.20, 0.25, 1.00),
    # 断面表示用 (いずれも不透明 — 図面として読ませるため)
    'sec_model':  (0.62, 0.58, 0.52, 1.00),
    'sec_ols':    (0.05, 0.78, 0.96, 1.00),
    'sec_dim':    (1.00, 0.72, 0.20, 1.00),
    'sec_base':   (0.45, 0.45, 0.45, 1.00),
    'sec_guide':  (0.10, 0.85, 1.00, 0.16),
    'sec_clip':   (0.72, 0.72, 0.72, 1.00),
    # 断面: 地表面と抵触部
    'sec_terrain': (0.30, 0.52, 0.24, 1.00),   # 地表面ライン (緑)
    'sec_ground':  (0.52, 0.42, 0.30, 1.00),   # 地盤の塗り (土色)
    'sec_over':    (0.95, 0.20, 0.25, 1.00),   # 制限表面より上に出た部分 (赤)
}


# =============================================================================
# SECTION 2 : ジオメトリコア (bpy 非依存)
# =============================================================================

def bearing_to_angle(bearing_deg: float) -> float:
    """真方位[deg] → 数学角[rad] (X=東 を 0、反時計回り正)"""
    return math.radians(90.0 - float(bearing_deg))


def angle_to_bearing(ang_rad: float) -> float:
    return (90.0 - math.degrees(ang_rad)) % 360.0


def rect_ring(length: float, width: float, bearing_deg: float):
    a = bearing_to_angle(bearing_deg)
    ux, uy = math.cos(a), math.sin(a)
    vx, vy = math.cos(a + math.pi / 2), math.sin(a + math.pi / 2)
    hl, hw = length / 2.0, width / 2.0
    return [
        (ux * hl + vx * hw, uy * hl + vy * hw),
        (-ux * hl + vx * hw, -uy * hl + vy * hw),
        (-ux * hl - vx * hw, -uy * hl - vy * hw),
        (ux * hl - vx * hw, uy * hl - vy * hw),
    ]


def circle_ring(radius: float, n: int = 48):
    return [(radius * math.cos(2 * math.pi * i / n),
             radius * math.sin(2 * math.pi * i / n)) for i in range(n)]


def rect_support(appr_ang: float, length: float, width: float, fato_bearing: float):
    """矩形着陸帯に対する、進入方位方向の支持距離(front)と半幅(halfWidth)"""
    fx, fy = math.cos(appr_ang), math.sin(appr_ang)
    px, py = math.cos(appr_ang + math.pi / 2), math.sin(appr_ang + math.pi / 2)
    a = bearing_to_angle(fato_bearing)
    ux, uy = math.cos(a), math.sin(a)
    vx, vy = math.cos(a + math.pi / 2), math.sin(a + math.pi / 2)
    hl, hw = length / 2.0, width / 2.0
    front = abs(fx * ux + fy * uy) * hl + abs(fx * vx + fy * vy) * hw
    half = abs(px * ux + py * uy) * hl + abs(px * vx + py * vy) * hw
    return front, half


def safety_area_margin(D: float, std: dict, manual: float = 0.0):
    """安全区域の幅 [m] と、その決定根拠を返す。

    整備指針の記述:
        安全区域は FATO 縁から 0.25D、ただし最低 3m。
    これを整理すると、分岐点は D = safetyOffset / safetyFactor (指針では 12m)。
        D >= 12m : 0.25D が支配 → 着陸帯径 = 1.5D + 2*0.25D = 2.0D
        D <  12m : 3m が支配    → 着陸帯径 = 1.5D + 6m
    """
    if manual and manual > 0:
        return float(manual), 'manual'
    prop = D * float(std['safetyFactor'])
    fixed = float(std['safetyOffset'])
    if prop >= fixed:
        return prop, 'proportional'      # 0.25D 支配
    return fixed, 'minimum'              # 3m 支配


def landing_area_threshold_D(std: dict) -> float:
    """SA の支配則が入れ替わる D 値 (指針では 12m)"""
    f = float(std['safetyFactor'])
    return float(std['safetyOffset']) / f if f > 0 else 0.0


def compute_facility_dimensions(std: dict, p) -> dict:
    """FATO / TLOF / 着陸帯(FATO+SA) の外形寸法を算定"""
    D = float(p.fato_d)
    afm = float(p.afm_required or 0.0)
    shape = p.fato_shape
    base_fato = max(D * std['fatoFactor'], afm)
    tlof_f = std['tlofFactorElevated'] if p.elevated else std['tlofFactorGround']
    base_tlof = max(D * tlof_f, afm)
    sa_marg, sa_rule = safety_area_margin(D, std, p.sa_margin_manual)
    brg = float(p.fato_bearing)

    d = dict(shape=shape, bearing=brg,
             fatoD=base_fato, fatoR=base_fato / 2.0,
             tlofD=base_tlof, tlofR=base_tlof / 2.0,
             saMarg=sa_marg, saRule=sa_rule,
             thresholdD=landing_area_threshold_D(std))

    if shape == 'rect':
        fl = max(p.rect_fato_l, base_fato)
        fw = max(p.rect_fato_w, base_fato)
        d.update(fatoL=fl, fatoW=fw,
                 tlofL=max(p.rect_tlof_l, base_tlof),
                 tlofW=max(p.rect_tlof_w, base_tlof),
                 # 着陸帯 = FATO + 全周 SA
                 laL=fl + 2 * sa_marg, laW=fw + 2 * sa_marg,
                 laR=base_fato / 2.0 + sa_marg,
                 laD=base_fato + 2 * sa_marg)
    else:
        d.update(fatoL=base_fato, fatoW=base_fato,
                 tlofL=base_tlof, tlofW=base_tlof,
                 laL=base_fato + 2 * sa_marg, laW=base_fato + 2 * sa_marg,
                 laR=base_fato / 2.0 + sa_marg,
                 laD=base_fato + 2 * sa_marg)
    return d


def approach_total_length(appr: dict) -> float:
    if appr.get('approachLength'):
        return float(appr['approachLength'])
    return float(appr.get('sec1Length', 0) + appr.get('sec2Length', 0) + appr.get('sec3Length', 0))


def divergence_rate(appr: dict, is_night: bool) -> float:
    if is_night and appr.get('sec1DivergenceNight'):
        return float(appr['sec1DivergenceNight'])
    return float(appr.get('sec1Divergence', 0.0))


def approach_half_width(dist: float, appr: dict, is_night: bool,
                        base_half: float, t_half: float) -> float:
    """進入表面内側縁 (= 着陸帯縁) からの距離 dist における半幅"""
    if appr.get('approachLength'):
        div = divergence_rate(appr, is_night)
        w = base_half + max(0.0, dist) * div
        return min(w, t_half) if t_half else w

    half, cum = base_half, 0.0
    if appr.get('sec1Length', 0) > 0:
        div = divergence_rate(appr, is_night)
        if dist <= appr['sec1Length']:
            w = half + dist * div
            return min(w, t_half) if t_half else w
        half += appr['sec1Length'] * div
        cum += appr['sec1Length']
    if appr.get('sec2Length', 0) > 0:
        div = appr.get('sec2Divergence', 0.0)
        local = dist - cum
        if local <= appr['sec2Length']:
            w = half + local * div
            return min(w, t_half) if t_half else w
        half += appr['sec2Length'] * div
        cum += appr['sec2Length']
    if appr.get('sec3Length', 0) > 0:
        div = appr.get('sec3Divergence', 0.0)
        local = dist - cum
        if local <= appr['sec3Length']:
            w = half + local * div
            return min(w, t_half) if t_half else w
        half += appr['sec3Length'] * div
    return min(half, t_half) if t_half else half


# ---------------------------------------------------------------------------
# 中心線: 区間定義 → 離散点列
#   区間: ('straight', L, 0.0) / ('arc', L, k)
#   k = 符号付き曲率。左旋回 +1/R、右旋回 -1/R
# ---------------------------------------------------------------------------

def plan_from_segments(total_len, segs_in, tail_mode='straight', tail_r=270.0):
    """区間テーブル方式。残長は tail_mode に従って自動充当する。"""
    out, remain = [], float(total_len)
    for s in segs_in:
        if remain <= 1e-6:
            break
        t = s.get('type', 'skip')
        L = float(s.get('len', 0.0))
        if t == 'skip' or L <= 0.0:
            continue
        L = min(L, remain)
        if t == 'straight':
            out.append(('straight', L, 0.0))
        else:
            R = max(1.0, float(s.get('R', 270.0)))
            k = (1.0 / R) if t == 'left' else (-1.0 / R)
            out.append(('arc', L, k))
        remain -= L

    if remain > 1e-6:
        if tail_mode == 'continue' and out and abs(out[-1][2]) > 1e-12:
            out.append(('arc', remain, out[-1][2]))
        elif tail_mode in ('left', 'right'):
            R = max(1.0, float(tail_r))
            k = (1.0 / R) if tail_mode == 'left' else (-1.0 / R)
            out.append(('arc', remain, k))
        else:
            out.append(('straight', remain, 0.0))
    return out


def plan_from_simple(total_len, straight0, radius, turn, turn_deg, curves, inter_s):
    """簡易モード: 初期直線 → 円弧×n (間に直線) → 残長を直線"""
    segs = []
    if straight0 > 0:
        segs.append({'type': 'straight', 'len': straight0})
    if turn in ('left', 'right') and radius > 0 and turn_deg > 0:
        arc_len = radius * math.radians(turn_deg)
        for i in range(max(1, int(curves))):
            segs.append({'type': turn, 'len': arc_len, 'R': radius})
            if i < curves - 1 and inter_s > 0:
                segs.append({'type': 'straight', 'len': inter_s})
    return plan_from_segments(total_len, segs, 'straight', radius)


def generate_centerline(sx, sy, init_ang, plan, step=10.0):
    """区間定義に沿って中心線を離散化。
    返値: [dict(x, y, dist, ang, k), ...]  k は各点における符号付き曲率"""
    pts = [dict(x=sx, y=sy, dist=0.0, ang=init_ang, k=(plan[0][2] if plan else 0.0))]
    x, y, ang, dist = sx, sy, init_ang, 0.0
    for kind, L, k in plan:
        n = max(2, int(math.ceil(L / max(0.5, step))))
        ds = L / n
        for _ in range(n):
            if kind == 'arc' and abs(k) > 1e-12:
                R = 1.0 / k                       # 符号付き半径
                cx = x - R * math.sin(ang)        # 旋回中心
                cy = y + R * math.cos(ang)
                ang += ds * k
                x = cx + R * math.sin(ang)
                y = cy - R * math.cos(ang)
            else:
                x += math.cos(ang) * ds
                y += math.sin(ang) * ds
            dist += ds
            pts.append(dict(x=x, y=y, dist=dist, ang=ang, k=k))
    return pts


def plan_metrics(plan):
    """区間定義の要約: 総延長・累積旋回角・最小R・曲線延長"""
    total = sum(L for _, L, _ in plan)
    turn = sum(L * k for kind, L, k in plan if kind == 'arc')
    curve_len = sum(L for kind, L, _ in plan if kind == 'arc')
    radii = [abs(1.0 / k) for kind, _, k in plan if kind == 'arc' and abs(k) > 1e-12]
    return dict(total=total, turn_deg=math.degrees(turn),
                curve_len=curve_len, min_R=(min(radii) if radii else None),
                n_seg=len(plan))


# ---------------------------------------------------------------------------
# 面の生成
# ---------------------------------------------------------------------------

def build_approach_grid(cl_pts, appr, is_night, base_half, t_half, slope):
    """進入表面 (2列 × N行のグリッド)"""
    verts, faces = [], []
    for p in cl_pts:
        perp = (math.cos(p['ang'] + math.pi / 2), math.sin(p['ang'] + math.pi / 2))
        hw = approach_half_width(p['dist'], appr, is_night, base_half, t_half)
        h = p['dist'] * slope
        verts.append((p['x'] - perp[0] * hw, p['y'] - perp[1] * hw, h))
        verts.append((p['x'] + perp[0] * hw, p['y'] + perp[1] * hw, h))
    for i in range(len(cl_pts) - 1):
        a, b, c, d = i * 2, i * 2 + 1, (i + 1) * 2, (i + 1) * 2 + 1
        faces.append((a, b, d, c))
    return verts, faces


def build_transition_grid(cl_pts, appr, is_night, base_half, t_half, slope,
                          trans_slope, trans_h, side, n_lat=6, fold_guard=0.95):
    """転移表面。進入表面と同一の中心線・同一の半幅則を使うため、
    進入表面が曲線を描けば転移表面もそのまま追従する。

    side = +1 : 進行方向左側 / -1 : 右側
    曲線内側では 横方向展開量が曲率半径を超えると面が折り返す。
    fold_guard で 0.95R に制限し、発生を warn として返す。
    """
    verts, faces = [], []
    rows = [p for p in cl_pts if p['dist'] * slope <= trans_h + 1e-6]
    if len(rows) < 2:
        return [], [], False
    cols = n_lat + 1
    folded = False
    for p in rows:
        perp = (math.cos(p['ang'] + math.pi / 2), math.sin(p['ang'] + math.pi / 2))
        hw = approach_half_width(p['dist'], appr, is_night, base_half, t_half)
        appr_h = p['dist'] * slope
        lateral_limit = max(0.0, (trans_h - appr_h) / trans_slope)

        # 曲線内側の折返し判定: side と曲率 k が同符号なら内側
        k = p.get('k', 0.0)
        if abs(k) > 1e-12 and (k > 0) == (side > 0):
            R = 1.0 / abs(k)
            allow = max(0.0, fold_guard * R - hw)
            if lateral_limit > allow:
                lateral_limit = allow
                folded = True

        for j in range(cols):
            t = j / n_lat
            lat = lateral_limit * t
            h = min(appr_h + lat * trans_slope, trans_h)
            verts.append((p['x'] + side * perp[0] * (hw + lat),
                          p['y'] + side * perp[1] * (hw + lat), h))
    for i in range(len(rows) - 1):
        for j in range(n_lat):
            a = i * cols + j
            b = i * cols + j + 1
            c = (i + 1) * cols + j
            d = (i + 1) * cols + j + 1
            faces.append((a, c, d, b) if side > 0 else (a, b, d, c))
    return verts, faces, folded


def build_pss_grid(dim, pss_h, pss_slope, n_az=96):
    """PSS: 着陸帯縁から立ち上がる全方位漏斗面"""
    lateral = max(0.0, pss_h / max(1e-6, pss_slope))
    verts, faces = [], []

    def base_r(az):
        if dim['shape'] != 'rect':
            return dim['laR']
        front, _ = rect_support(az, dim['laL'], dim['laW'], dim['bearing'])
        return front

    for i in range(n_az + 1):
        a = 2 * math.pi * i / n_az
        cs, sn = math.cos(a), math.sin(a)
        r0 = base_r(a)
        verts.append((cs * r0, sn * r0, 0.0))
        verts.append((cs * (r0 + lateral), sn * (r0 + lateral), pss_h))
    for i in range(n_az):
        a, b, c, d = i * 2, i * 2 + 1, (i + 1) * 2, (i + 1) * 2 + 1
        faces.append((a, c, d, b))
    return verts, faces


# =============================================================================
# SECTION 3 : リアルタイム更新フック
# =============================================================================

_UPDATING = False


def _auto_update(self, context):
    """プロパティ変更時に OLS を再生成する。再入と例外は握りつぶす。"""
    global _UPDATING
    if _UPDATING:
        return
    scn = getattr(context, "scene", None) or bpy.context.scene
    p = getattr(scn, "vpols", None)
    if p is None or not p.live_update:
        return
    _UPDATING = True
    try:
        regenerate(p)
    except Exception as e:
        try:
            p.summary = f"再生成エラー: {e}"
        except Exception:
            pass
    finally:
        _UPDATING = False


UP = {'update': _auto_update}


def _origin_update(self, context):
    """origin_x/y/z の数値編集 → 原点Empty へ反映してから再生成。
    (Empty を直接動かした場合はこの関数を通らず、再生成時に
     get_site_root が Empty→props の読み戻しを行う)"""
    global _UPDATING
    if _UPDATING:
        return
    scn = getattr(context, "scene", None) or bpy.context.scene
    p = getattr(scn, "vpols", None)
    if p is None:
        return
    _UPDATING = True
    try:
        # self が編集されたサイト本体。active かどうかに依存しない
        sync_root_from_props(self)
        if p.live_update:
            regenerate_site(p, self)
    except Exception as e:
        try:
            p.summary = f"原点更新エラー: {e}"
        except Exception:
            pass
    finally:
        _UPDATING = False


OUP = {'update': _origin_update}


# =============================================================================
# SECTION 4 : プロパティ定義
# =============================================================================

SEG_TYPE_ITEMS = [
    ('skip', '—（未使用）', ''),
    ('straight', '直線', ''),
    ('left', '左曲線', ''),
    ('right', '右曲線', ''),
]


class VPOLS_Direction(PropertyGroup):
    enabled: BoolProperty(name="有効", default=True, **UP)
    bearing: FloatProperty(name="進入方位 [°]", default=0.0, min=0.0, max=360.0, **UP)
    trans_side: EnumProperty(name="転移表面", default='both', **UP,
                             items=[('both', '両側', ''), ('left', '左側のみ', ''),
                                    ('right', '右側のみ', ''), ('none', 'なし', '')])
    # HTML Viewer ⇄ Blender の曲率符号規約補正。
    # +1: Blenderネイティブ (left=+1/R, right=-1/R)
    # -1: HTML Viewer交換互換。ラベル(left/right)は維持したまま幾何だけ反転する。
    exchange_curve_sign: FloatProperty(name="交換曲線符号", default=1.0, options={'HIDDEN'})
    # --- モード切替 ---
    use_segments: BoolProperty(name="区間テーブルで定義", default=False, **UP,
                               description="直線/曲線を最大6区間まで自由に組み合わせる")
    # --- 簡易モード ---
    straight0: FloatProperty(name="初期直線長 S [m]", default=300.0, min=0.0, **UP,
                             description="着陸帯縁から最初の曲線開始までの直線長")
    radius: FloatProperty(name="曲線半径 R [m]", default=270.0, min=1.0, **UP)
    turn: EnumProperty(name="旋回方向", default='none', **UP,
                       items=[('none', '直線のみ', ''), ('left', '左', ''), ('right', '右', '')])
    turn_deg: FloatProperty(name="1曲線あたり旋回角 [°]", default=30.0, min=0.0, max=36000.0, **UP)
    curves: IntProperty(name="曲線数", default=1, min=1, max=4, **UP)
    inter_s: FloatProperty(name="曲線間直線長 [m]", default=150.0, min=0.0, **UP)
    # --- 区間モードの末端処理 ---
    tail_mode: EnumProperty(name="残長の処理", default='straight', **UP,
                            items=[('straight', '直線で充当', ''),
                                   ('continue', '最終区間の曲率を継続', ''),
                                   ('left', '左曲線で充当', ''),
                                   ('right', '右曲線で充当', '')])
    tail_r: FloatProperty(name="残長曲線 R [m]", default=270.0, min=1.0, **UP)


# 区間 1..6 のプロパティを注釈へ追加 (Blender は register 時に __annotations__ を読む)
for _i in range(1, 7):
    VPOLS_Direction.__annotations__[f"seg{_i}_type"] = EnumProperty(
        name=f"区間{_i}", items=SEG_TYPE_ITEMS,
        default='straight' if _i == 1 else 'skip', **UP)
    VPOLS_Direction.__annotations__[f"seg{_i}_len"] = FloatProperty(
        name="長さ [m]", default=(300.0 if _i == 1 else 0.0), min=0.0, **UP)
    VPOLS_Direction.__annotations__[f"seg{_i}_r"] = FloatProperty(
        name="R [m]", default=270.0, min=1.0, **UP)


def direction_segments(dir_p):
    """Direction プロパティ → 区間 dict のリスト"""
    out = []
    for i in range(1, 7):
        out.append({'type': getattr(dir_p, f"seg{i}_type"),
                    'len': getattr(dir_p, f"seg{i}_len"),
                    'R': getattr(dir_p, f"seg{i}_r")})
    return out


def direction_plan(dir_p, total_len):
    if dir_p.use_segments:
        plan = plan_from_segments(total_len, direction_segments(dir_p),
                                  dir_p.tail_mode, dir_p.tail_r)
    else:
        plan = plan_from_simple(total_len, dir_p.straight0, dir_p.radius,
                                dir_p.turn, dir_p.turn_deg, dir_p.curves, dir_p.inter_s)

    # HTML Viewerは画面/地図側の曲線符号規約がBlenderネイティブと逆。
    # 交換データのcurveSignFactorで幾何の曲率だけ補正し、UI上の「左/右」表記は維持する。
    sign = -1.0 if getattr(dir_p, 'exchange_curve_sign', 1.0) < 0.0 else 1.0
    if sign < 0.0:
        plan = [(kind, L, (-k if kind == 'arc' else k)) for kind, L, k in plan]
    return plan


class VPOLS_Result(PropertyGroup):
    """1レコード = 1つの「抵触箇所」(制限表面から突出した連続領域)。
    同一オブジェクトでも、突出位置が分離していれば別レコードになる。"""
    rank: IntProperty(name="No")
    obj_name: StringProperty(name="オブジェクト")
    spot_no: IntProperty(name="箇所番号")          # オブジェクト内での通し番号
    spots_in_obj: IntProperty(name="同一物件の箇所数")

    max_excess: FloatProperty(name="最大超過 [m]")   # 面からの突出量の最大
    peak_z: FloatProperty(name="領域内最高点 Z [m]")
    surf_z: FloatProperty(name="代表点の面高 [m]")
    surface: StringProperty(name="支配面")

    rx: FloatProperty()      # 代表点(標点)
    ry: FloatProperty()
    rz: FloatProperty()

    n_points: IntProperty(name="突出サンプル点数")
    area_m2: FloatProperty(name="突出概算面積 [m2]")
    ext_x: FloatProperty(name="領域幅X [m]")
    ext_y: FloatProperty(name="領域幅Y [m]")
    obj_min_clearance: FloatProperty(name="物件の最小垂直離隔 [m]")


def _sec_update(self, context):
    """断面パラメータ変更時の再構築 (OLS 本体とは独立したフック)"""
    global _UPDATING
    if _UPDATING:
        return
    scn = getattr(context, "scene", None) or bpy.context.scene
    p = getattr(scn, "vpols", None)
    if p is None or not p.sec.live:
        return
    _UPDATING = True
    try:
        rebuild_section(p)
    except Exception as e:
        try:
            p.sec.info = f"断面エラー: {e}"
        except Exception:
            pass
    finally:
        _UPDATING = False


SU = {'update': _sec_update}


class VPOLS_SectionPoint(PropertyGroup):
    """3Dビューで手描きした断面ラインのXY頂点。"""
    x: FloatProperty(name="X [m]")
    y: FloatProperty(name="Y [m]")


class VPOLS_Section(PropertyGroup):
    live: BoolProperty(name="断面もリアルタイム更新", default=True)

    mode: EnumProperty(
        name="断面種別", default='LONG', **SU,
        items=[('LONG', '縦断面 (進入中心線に沿う)', '曲線中心線に追従して展開する'),
               ('CROSS', '横断面 (中心線に直交)', '指定距離での直交断面'),
               ('FREE', '任意断面 (始点+方位)', '中心線と無関係な自由断面'),
               ('DRAW', '手描き断面 (3Dビューで指定)', 'クリックした折れ線・曲線状ラインに沿う断面')])

    # --- 縦断/横断の基準となる進入方向 ---
    dir_idx: IntProperty(name="進入方向", default=1, min=1, max=2, **SU)

    # --- 縦断 ---
    long_offset: FloatProperty(name="中心線からの横オフセット [m]", default=0.0, **SU,
                               description="正=進行方向左、負=右。翼端側の断面を見るときに使う")
    sta_from: FloatProperty(name="開始距離 [m]", default=-50.0, **SU,
                            description="負値は着陸帯側へ延長する")
    sta_to: FloatProperty(name="終了距離 [m]", default=600.0, **SU)

    # --- 横断 ---
    cross_sta: FloatProperty(name="横断位置 距離 [m]", default=200.0, **SU)
    cross_half: FloatProperty(name="横断 片側幅 [m]", default=200.0, min=1.0, **SU)

    # --- 任意断面 ---
    free_x: FloatProperty(name="始点 X [m]", default=0.0, **SU)
    free_y: FloatProperty(name="始点 Y [m]", default=0.0, **SU)
    free_brg: FloatProperty(name="断面方位 [°]", default=0.0, min=0.0, max=360.0, **SU)
    free_len: FloatProperty(name="断面長 [m]", default=500.0, min=1.0, **SU)

    # --- 3Dビュー手描き断面 ---
    draw_plane_z: FloatProperty(
        name="作図面 Z [m]", default=0.0,
        description="3Dビューのクリックを投影する水平面。平面図・斜視図の双方で使用")
    draw_points: CollectionProperty(type=VPOLS_SectionPoint)
    guide_show: BoolProperty(name="切断ライン・ガイド面を表示", default=True, **SU)
    guide_margin_z: FloatProperty(name="ガイド上下余裕 [m]", default=20.0, min=0.0, **SU)

    # --- 一時カットアウェイ表示 ---
    clip_target: EnumProperty(
        name="見え消し対象", default='BOTH',
        items=[('BOTH', '都市モデル + OLS', ''),
               ('CITY', '都市モデルのみ', ''),
               ('OLS', 'OLSのみ', '')])
    clip_hide_side: EnumProperty(
        name="非表示にする側", default='VIEW',
        items=[('VIEW', '現在の視点側', '実行時の3Dビュー位置に近い側を隠す'),
               ('LEFT', 'ライン進行方向の左側', ''),
               ('RIGHT', 'ライン進行方向の右側', '')])
    clip_offset: FloatProperty(
        name="見え消し境界オフセット [m]", default=0.0,
        description="正値でライン進行方向の左側へ境界を移動")
    clip_curve_tol: FloatProperty(
        name="曲線近似許容差 [m]", default=1.0, min=0.05, max=20.0,
        description="曲線断面ラインを見え消し計算用の折れ線へ簡略化する許容差")
    clip_epsilon: FloatProperty(
        name="境界許容差 [m]", default=0.02, min=0.0, max=1.0,
        description="境界上の微小な数値誤差を吸収する")
    clip_max_tris: IntProperty(
        name="境界物件の最大三角形数", default=1000000, min=1000, max=10000000,
        description="境界を跨ぐ1オブジェクトがこの数を超える場合は安全のためクリップを省略")
    cutaway_active: BoolProperty(name="カットアウェイ適用中", default=False)
    clip_info: StringProperty(default="")

    # --- 離散化 ---
    step: FloatProperty(name="断面刻み [m]", default=2.0, min=0.2, max=50.0, **SU,
                        description="小さいほど断面形状が滑らかになるが重くなる")

    # --- 都市モデル断面の塗り ---
    fill_mode: EnumProperty(
        name="塗り方", default='SOLID', **SU,
        items=[('SOLID', '最下点〜最高点をベタ塗り', 'LOD1 の押出し形状に最適'),
               ('SPANS', '交点をペアリング (中空も表現)', 'ピロティ・アーケード等')])
    floor_mode: EnumProperty(
        name="底面の基準", default='TERRAIN', **SU,
        items=[('TERRAIN', '地表面に追従 (地形があれば)', '建物底面が無い場合は地表面高で閉じる'),
               ('CONST', '一定 Z', '常に fill_floor の高さで閉じる')])
    fill_floor: FloatProperty(name="底面 Z (一定/地形なし時) [m]", default=0.0, **SU,
                              description="地形が無い測点・CONSTモード時の底面高")
    show_terrain: BoolProperty(name="地表面ラインを描く", default=True, **SU)
    ground_fill: BoolProperty(name="地盤を塗る", default=True, **SU,
                              description="地表面ラインから下を土色で塗り、断面図として読めるようにする")
    ground_depth: FloatProperty(name="地盤の塗り厚 [m]", default=10.0, min=0.5, **SU)
    split_over: BoolProperty(name="制限表面より上を赤で塗り分け", default=True, **SU,
                             description="建物断面のうち制限表面を超えた部分を抵触色で強調する")

    # --- 配置 ---
    placement: EnumProperty(
        name="配置", default='INPLACE', **SU,
        items=[('INPLACE', '現地建て (3Dモデルに重ねる)', '断面をその位置に垂直に立てる'),
               ('SHEET', '展開図 (図面として並べる)', '距離を X 軸に展開した断面図')])
    sheet_x: FloatProperty(name="図面原点 X [m]", default=0.0, **SU)
    sheet_y: FloatProperty(name="図面原点 Y [m]", default=-400.0, **SU)
    sheet_z: FloatProperty(name="図面原点 Z [m]", default=0.0, **SU)
    v_exag: FloatProperty(name="縦倍率", default=1.0, min=0.1, max=20.0, **SU,
                          description="展開図のみ有効。縦断は 2〜5 倍が読みやすい")

    # --- 離隔寸法 ---
    dim_show: BoolProperty(name="垂直離隔の寸法を表示", default=True, **SU)
    dim_span: FloatProperty(name="寸法スパン [m]", default=50.0, min=1.0, **SU,
                            description="この間隔ごとに 制限表面−モデル頂部 の離隔を記入")
    dim_text_h: FloatProperty(name="文字高 [m]", default=6.0, min=0.1, **SU)
    dim_max: IntProperty(name="寸法の最大本数", default=120, min=1, max=500, **SU)
    dim_only_top: BoolProperty(name="モデルがある位置のみ寸法を記入", default=True, **SU)

    info: StringProperty(default="")


class VPOLS_Site(PropertyGroup):
    """1つのバーティポート (制限表面一式)。
    複数サイトを並置し、それぞれ独立に原点・諸元を微調整できる。

    原点は「原点Empty」(VP原点_xxx) が正本。全OLSオブジェクトはこの
    Empty の子としてローカル座標で生成されるため、Empty を掴んで動かせば
    表面一式が追従し、再生成しても位置は保持される。
    """
    name: StringProperty(name="サイト名", default="VP-1")
    uid: IntProperty(default=0)          # コレクション名の固定キー (改名で孤児化しない)
    origin_x: FloatProperty(name="原点 X [m]", default=0.0, **OUP)
    origin_y: FloatProperty(name="原点 Y [m]", default=0.0, **OUP)
    origin_z: FloatProperty(name="原点 Z [m]", default=0.0, **OUP,
                            description="FATO面のワールド高さ。3Dカーソル取込で設定される")

    # --- 規格・FATO ---
    standard: EnumProperty(name="適用規格", items=STANDARD_ITEMS,
                           default='mlit_vertiport', **UP)
    fato_d: FloatProperty(name="D値 (機体最大寸法) [m]", default=15.0, min=1.0, **UP)
    afm_required: FloatProperty(name="AFM要求FATO寸法 [m]", default=0.0, min=0.0, **UP)
    fato_shape: EnumProperty(name="FATO形状", default='circle', **UP,
                             items=[('circle', '円形', ''), ('rect', '矩形', '')])
    fato_bearing: FloatProperty(name="FATO方位 [°]", default=0.0, min=0.0, max=360.0, **UP)
    rect_fato_l: FloatProperty(name="矩形FATO 長辺 [m]", default=0.0, min=0.0, **UP)
    rect_fato_w: FloatProperty(name="矩形FATO 短辺 [m]", default=0.0, min=0.0, **UP)
    rect_tlof_l: FloatProperty(name="矩形TLOF 長辺 [m]", default=0.0, min=0.0, **UP)
    rect_tlof_w: FloatProperty(name="矩形TLOF 短辺 [m]", default=0.0, min=0.0, **UP)
    sa_margin_manual: FloatProperty(name="安全区域幅 (手動上書き) [m]", default=0.0, min=0.0, **UP,
                                    description="0 のとき指針式 max(3m, 0.25D) を自動適用")
    elevated: BoolProperty(name="屋上/高架式", default=False, **UP)
    night: BoolProperty(name="夜間VFR (拡幅15% / max10D)", default=False, **UP)

    # --- 地理参照 (PLATEAU等) ---
    geo_lat: FloatProperty(name="緯度 [°]", default=35.681236, min=-90.0, max=90.0,
                           description="アクティブVPのJGD2011/WGS84緯度。PLATEAU取得中心")
    geo_lon: FloatProperty(name="経度 [°]", default=139.767125, min=-180.0, max=180.0,
                           description="アクティブVPのJGD2011/WGS84経度。PLATEAU取得中心")

    # --- 表面パラメータ ---
    slope: FloatProperty(name="進入勾配", default=0.125, min=0.001, max=1.0, **UP,
                         description="1:8 = 0.125")
    trans_h: FloatProperty(name="転移面 高さ [m]", default=45.0, min=0.0, **UP)
    trans_slope: FloatProperty(name="転移面 勾配", default=0.5, min=0.01, **UP,
                               description="1:2 = 0.5")
    pss_apply: BoolProperty(name="PSS(保護面)を生成", default=False, **UP)
    pss_h: FloatProperty(name="PSS 高さ [m]", default=10.0, min=0.0, **UP)
    pss_slope: FloatProperty(name="PSS 勾配", default=1.0, min=0.01, **UP)
    make_inner: BoolProperty(name="内側水平面を生成", default=False, **UP)

    # --- 基準面 (原点Zに加算されるローカルオフセット) ---
    z_datum: EnumProperty(name="Z基準", default='AMSL', **UP,
                          items=[('AMSL', '標高 T.P. (都市モデルと同一)', ''),
                                 ('BASE', 'OLS基面 = 原点Z', '')])
    site_elev: FloatProperty(name="地盤標高 T.P. [m]", default=0.0, **UP)
    pad_h: FloatProperty(name="パッド高 (地盤→FATO面) [m]", default=0.0, **UP)
    ols_base_h: FloatProperty(name="OLS基面オフセット [m]", default=0.0, **UP)

    # --- 方位補正 ---
    use_grid_north: BoolProperty(name="平面直角座標系のグリッド北で作図", default=False, **UP,
                                 description="真方位から子午線収差角 γ を差し引く")
    convergence: FloatProperty(name="子午線収差角 γ [°]", default=0.0, **UP)

    # --- 離散化 ---
    cl_step: FloatProperty(name="中心線分割長 [m]", default=10.0, min=1.0, max=100.0, **UP)
    trans_lat_div: IntProperty(name="転移面 横分割数", default=6, min=2, max=24, **UP)

    dir1: PointerProperty(type=VPOLS_Direction)
    dir2: PointerProperty(type=VPOLS_Direction)



# =============================================================================
# SECTION 4.5 : PLATEAU 連携プロパティ
# =============================================================================

PLATEAU_LOD_ITEMS = [
    ('LOD1', 'LOD1', '箱形状を基本とする軽量モデル'),
    ('LOD2', 'LOD2', '屋根形状等を含む詳細モデル'),
    ('LOD3', 'LOD3', '開口部等を含む高詳細モデル（存在する場合）'),
    ('LOD4', 'LOD4', '屋内等を含む最高詳細モデル（存在する場合）'),
    ('SKIP', '読み込まない', 'この距離帯の建物を除外'),
]


class VPOLS_PlateauLodBand(PropertyGroup):
    min_dist: FloatProperty(name="開始 [m]", default=0.0, min=0.0)
    max_dist: FloatProperty(name="終了 [m]", default=500.0, min=0.0)
    lod: EnumProperty(name="LOD", items=PLATEAU_LOD_ITEMS, default='LOD2')


class VPOLS_PlateauFile(PropertyGroup):
    selected: BoolProperty(name="取得", default=True)
    feature_type: StringProperty(name="種別", default="bldg")
    code: StringProperty(name="メッシュ")
    city: StringProperty(name="都市")
    year: IntProperty(name="年度", default=0)
    max_lod: IntProperty(name="MaxLOD", default=0)
    file_size: IntProperty(name="bytes", default=0)
    features: IntProperty(name="地物数", default=0)
    lod1: IntProperty(default=0)
    lod2: IntProperty(default=0)
    url: StringProperty()


class VPOLS_Props(PropertyGroup):
    live_update: BoolProperty(name="リアルタイム更新", default=True,
                              description="数値を変更した瞬間にアクティブサイトの制限表面を再生成する")
    schema_version: IntProperty(name="内部スキーマ", default=806, options={'HIDDEN'})

    # --- サイト (複数の制限表面) ---
    sites: CollectionProperty(type=VPOLS_Site)
    site_index: IntProperty(default=0, **UP)
    site_seq: IntProperty(default=0)     # uid 採番カウンタ

    # --- HTML/Blender共通交換座標基準 ---
    exchange_ref_valid: BoolProperty(name="交換座標基準を使用", default=False)
    exchange_crs_type: StringProperty(name="水平座標系", default="jgd2011-tm")
    exchange_datum: StringProperty(name="測地基準", default="JGD2011")
    exchange_epsg: IntProperty(name="EPSG", default=0, min=0)
    exchange_zone: IntProperty(name="平面直角座標系", default=0, min=0, max=19)
    exchange_origin_lat: FloatProperty(name="基準緯度 [deg]", default=0.0, precision=8)
    exchange_origin_lng: FloatProperty(name="基準経度 [deg]", default=0.0, precision=8)
    exchange_origin_e: FloatProperty(name="基準点 E [m]", default=0.0, precision=4)
    exchange_origin_n: FloatProperty(name="基準点 N [m]", default=0.0, precision=4)
    exchange_convergence: FloatProperty(name="子午線収差 γ [deg]", default=0.0, precision=6)
    exchange_vertical_datum: StringProperty(name="標高基準", default="TP/AMSL")

    # --- PLATEAU API / 距離LOD ---
    plateau_api_base: StringProperty(
        name="API URL", default="https://api.plateauview.mlit.go.jp",
        description="PLATEAU 配信サービス REST API のベースURL")
    plateau_radius: FloatProperty(name="取得半径 [m]", default=1500.0, min=50.0, max=50000.0)
    plateau_query_margin: FloatProperty(name="検索余裕 [m]", default=50.0, min=0.0, max=5000.0,
                                        description="BBOX検索時に取得半径へ加算する余裕")
    plateau_cache_dir: StringProperty(name="キャッシュ", subtype='DIR_PATH', default=VPOLS_DEFAULT_CACHE_DIR,
                                        description="PLATEAU CityGMLの保存先。書込不可の場合はLOCALAPPDATA/TEMPへ自動フォールバック")
    plateau_use_cache: BoolProperty(name="キャッシュを使用", default=True)
    plateau_max_download_mb: FloatProperty(name="1ファイル上限 [MB]", default=300.0, min=1.0, max=5000.0)
    plateau_max_buildings: IntProperty(name="最大建物数", default=20000, min=100, max=500000)
    plateau_fallback_lower: BoolProperty(name="不足LODは下位LODへフォールバック", default=True,
                                         description="例: LOD2指定でLOD2が無ければLOD1を使用")
    plateau_replace: BoolProperty(name="既存PLATEAUモデルを置換", default=True)
    plateau_auto_target: BoolProperty(name="都市モデルへ自動設定", default=True,
                                      description="取込後にPLATEAUコレクションを抵触照査の都市モデルへ設定")
    plateau_lod_bands: CollectionProperty(type=VPOLS_PlateauLodBand)
    plateau_lod_index: IntProperty(default=0)
    plateau_files: CollectionProperty(type=VPOLS_PlateauFile)
    plateau_file_index: IntProperty(default=0)
    plateau_status: StringProperty(default="")
    # PLATEAU地形モデル(dem)は建築物と別Collectionで管理する。
    plateau_dem_files: CollectionProperty(type=VPOLS_PlateauFile)
    plateau_dem_file_index: IntProperty(default=0)
    plateau_dem_replace: BoolProperty(name="既存PLATEAU DEMを置換", default=True)
    plateau_auto_terrain: BoolProperty(name="地形(DEM)へ自動設定", default=True,
                                       description="DEM取込後に専用Collectionをterrain_collへ設定")
    plateau_dem_max_triangles: IntProperty(name="最大DEM三角形数", default=300000, min=1000, max=5000000,
                                            description="安全のための上限。超過分は読み込まない")
    plateau_dem_status: StringProperty(default="")
    plateau_last_url: StringProperty(name="最終API URL", default="")
    plateau_last_error: StringProperty(name="PLATEAUエラー", default="")
    plateau_timeout: IntProperty(name="通信タイムアウト [秒]", default=45, min=5, max=300)

    # --- PLATEAU取得進捗 / 診断UI ---
    plateau_progress_active: BoolProperty(default=False, options={'HIDDEN'})
    plateau_progress_cancel_requested: BoolProperty(default=False, options={'HIDDEN'})
    plateau_progress_overall: FloatProperty(name="全体進捗", default=0.0, min=0.0, max=1.0, subtype='FACTOR')
    plateau_progress_file: FloatProperty(name="現在ファイル", default=0.0, min=0.0, max=1.0, subtype='FACTOR')
    plateau_progress_phase: StringProperty(name="現在フェーズ", default="待機")
    plateau_progress_file_name: StringProperty(name="現在ファイル名", default="")
    plateau_progress_detail: StringProperty(name="進捗詳細", default="")
    plateau_progress_file_index: IntProperty(default=0, min=0)
    plateau_progress_file_total: IntProperty(default=0, min=0)
    plateau_progress_current_mb: FloatProperty(default=0.0, min=0.0)
    plateau_progress_total_mb: FloatProperty(default=0.0, min=0.0)
    plateau_progress_processed: IntProperty(default=0, min=0)
    plateau_progress_processed_label: StringProperty(default="")

    # --- 照査 ---
    target_coll: PointerProperty(name="都市モデル", type=bpy.types.Collection)
    terrain_coll: PointerProperty(
        name="地形 (DEM)", type=bpy.types.Collection,
        description="地表面メッシュのコレクション。断面に地表面ラインとして描画される。"
                    "障害物照査の対象にしないため、都市モデルとは別コレクションにすること")
    threshold: FloatProperty(name="抵触判定しきい値 [m]", default=0.0,
                             description="面より上に出た量がこの値を超えたら突出とみなす")

    # サンプリング
    sample_step: FloatProperty(name="サンプル間隔 [m]", default=3.0, min=0.3,
                               description="辺上・上向き面上を刻む間隔。突出領域の分解能を決める")
    sample_edge: BoolProperty(name="辺上をサンプリング", default=True)
    sample_grid: BoolProperty(name="上向き面(屋上)を格子サンプリング", default=True,
                              description="屋上面の突出範囲を面的に捉える。領域分離の精度に直結")
    sample_face: BoolProperty(name="面中心も追加", default=True)
    max_samples_per_obj: IntProperty(name="1物件あたり最大サンプル数", default=20000, min=200,
                                     description="超える場合は格子間隔を自動的に粗くする")

    # 突出領域の分離
    spot_link: FloatProperty(name="領域分離距離 [m]", default=10.0, min=0.5,
                             description="突出点どうしがこの距離以内なら同一の抵触箇所とみなす")
    min_spot_points: IntProperty(name="最小構成点数", default=1, min=1,
                                 description="これ未満の点数の領域はノイズとして棄却")
    rep_mode: EnumProperty(name="代表点(標点)", default='EXCESS',
                           items=[('EXCESS', '最大超過点', '制限表面からの突出量が最大の点'),
                                  ('HEIGHT', '最高標高点', '領域内で標高が最も高い点')])

    make_markers: BoolProperty(name="抵触箇所にマーカー生成", default=True)
    make_point_cloud: BoolProperty(name="突出点群を可視化", default=True,
                                   description="突出したサンプル点を頂点メッシュとして出力")
    slab_thickness: FloatProperty(name="抵触体積 抽出スラブ厚 [m]", default=400.0, min=10.0)

    sec: PointerProperty(type=VPOLS_Section)

    results: CollectionProperty(type=VPOLS_Result)
    result_index: IntProperty(default=0)
    summary: StringProperty(default="")
    geo_info: StringProperty(default="")
    warn: StringProperty(default="")


def active_site(p):
    if 0 <= p.site_index < len(p.sites):
        return p.sites[p.site_index]
    return None


# =============================================================================
# SECTION 5 : Blender ユーティリティ
# =============================================================================

COLL_SITES = "VP_OLS"                 # 全サイトを束ねる親コレクション
COLL_MARK = "VP_OLS_抵触点"
COLL_CUT = "VP_OLS_抵触部"


def site_coll_name(site) -> str:
    return f"VP_SITE_{site.uid:03d}"      # uid 固定名 (サイト改名で孤児化しない)


def site_root_name(site) -> str:
    return f"VP原点_{site.uid:03d}"


def get_collection(name: str, parent=None):
    parent = parent or bpy.context.scene.collection
    coll = bpy.data.collections.get(name)
    if coll is None:
        coll = bpy.data.collections.new(name)
    if name not in parent.children:
        try:
            parent.children.link(coll)
        except Exception:
            pass    # 既に別の親にリンク済み
    return coll


def get_site_collection(site):
    parent = get_collection(COLL_SITES)
    return get_collection(site_coll_name(site), parent)


def get_site_root(site, coll):
    """サイトの原点Empty。無ければ site.origin_* の位置に作成する。
    既にあれば「Emptyの現在位置」が正 — ユーザーが動かした位置を
    site.origin_* へ読み戻す (これにより移動が再生成で失われない)。"""
    name = site_root_name(site)
    root = coll.objects.get(name)
    if root is None:
        root = bpy.data.objects.get(name)
        if root is not None and root.name not in coll.objects:
            try:
                coll.objects.link(root)
            except Exception:
                pass
    if root is None:
        root = bpy.data.objects.new(name, None)
        root.empty_display_type = 'PLAIN_AXES'
        root.empty_display_size = 10.0
        root.location = (site.origin_x, site.origin_y, site.origin_z)
        coll.objects.link(root)
    else:
        # 読み戻し (update コールバックの再帰を避けるため値が違うときだけ)
        loc = root.location
        for attr, v in (("origin_x", loc.x), ("origin_y", loc.y), ("origin_z", loc.z)):
            if abs(getattr(site, attr) - v) > 1e-9:
                site[attr] = v          # ID プロパティ直書きで update を発火させない
    root["vpols_site_root"] = True
    return root


def sync_root_from_props(site):
    """origin_x/y/z の数値編集を Empty の位置へ反映する。"""
    coll = bpy.data.collections.get(site_coll_name(site))
    root = (coll.objects.get(site_root_name(site)) if coll else
            bpy.data.objects.get(site_root_name(site)))
    if root is not None:
        root.location = (site.origin_x, site.origin_y, site.origin_z)


def clear_collection(coll):
    for obj in list(coll.objects):
        data = obj.data
        bpy.data.objects.remove(obj, do_unlink=True)
        if isinstance(data, bpy.types.Mesh) and data.users == 0:
            bpy.data.meshes.remove(data)


def get_material(kind: str):
    name = f"VPOLS_{kind}"
    mat = bpy.data.materials.get(name)
    if mat:
        return mat
    r, g, b, a = SURFACE_COLORS.get(kind, (0.5, 0.5, 0.5, 0.4))
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
        if "Alpha" in bsdf.inputs:
            bsdf.inputs["Alpha"].default_value = a
        if "Roughness" in bsdf.inputs:
            bsdf.inputs["Roughness"].default_value = 0.6
    mat.diffuse_color = (r, g, b, a)
    if a < 1.0:
        for attr, val in (("blend_method", 'BLEND'), ("surface_render_method", 'BLENDED')):
            try:
                setattr(mat, attr, val)
            except Exception:
                pass
    try:
        mat.use_backface_culling = False
    except Exception:
        pass
    return mat


def upsert_mesh_object(name, verts, faces, kind, coll, z_offset=0.0, edges=None,
                       parent=None):
    """同名オブジェクトがあればメッシュだけ差し替える。
    リアルタイム更新でオブジェクトを作り直さないため、選択状態・
    ビューポート設定・マテリアルが保持され、更新が軽い。

    parent を渡すと、その原点Empty の子としてローカル座標で保持される。
    サイトの移動は Empty 側で行うため、再生成しても位置が失われない。
    parent なし (断面等) は従来どおりワールド座標。"""
    if z_offset:
        verts = [(v[0], v[1], v[2] + z_offset) for v in verts]
    obj = coll.objects.get(name)
    if obj is None or obj.type != 'MESH':
        me = bpy.data.meshes.new(name)
        obj = bpy.data.objects.new(name, me)
        coll.objects.link(obj)
        me.materials.append(get_material(kind))
    else:
        me = obj.data
        me.clear_geometry()
        if not me.materials:
            me.materials.append(get_material(kind))
    me.from_pydata([Vector(v) for v in verts], edges or [], faces or [])
    me.validate(verbose=False)
    me.update()
    obj["vpols_kind"] = kind
    obj.color = SURFACE_COLORS.get(kind, (0.5, 0.5, 0.5, 1.0))
    if parent is not None:
        if obj.parent != parent:
            obj.parent = parent
        obj.matrix_parent_inverse.identity()
        obj.matrix_basis.identity()      # 親に対するローカル位置は常に一致
    else:
        obj.parent = None
        obj.matrix_world.identity()
    return obj


def base_amsl(site) -> float:
    return float(site.site_elev) + float(site.pad_h) + float(site.ols_base_h)


def z_offset_for(site) -> float:
    """原点Empty からのローカル Z オフセット"""
    return base_amsl(site) if site.z_datum == 'AMSL' else 0.0


def effective_bearing(site, bearing: float) -> float:
    return bearing - site.convergence if site.use_grid_north else bearing


# =============================================================================
# SECTION 6 : OLS 生成
# =============================================================================

def regenerate_site(p, site):
    """指定サイトの OLS 一式を、そのサイトの原点Empty の子として再生成する。"""
    std = STANDARDS[site.standard]
    appr = std['appr']
    dim = compute_facility_dimensions(std, site)
    is_night = site.night
    slope = site.slope
    total_len = approach_total_length(appr)
    D = site.fato_d

    if appr.get('maxWidthFactorDay'):
        t_half = D * (appr['maxWidthFactorNight'] if is_night
                      else appr['maxWidthFactorDay']) / 2.0
    else:
        t_half = dim['laR']

    coll = get_site_collection(site)
    root = get_site_root(site, coll)
    zoff = z_offset_for(site)
    warnings = []
    alive = {root.name}

    # ---- FATO / TLOF / 着陸帯 ----
    brg_eff = effective_bearing(site, dim['bearing'])
    if dim['shape'] == 'rect':
        fato_ring = rect_ring(dim['fatoL'], dim['fatoW'], brg_eff)
        tlof_ring = rect_ring(dim['tlofL'], dim['tlofW'], brg_eff)
        la_ring = rect_ring(dim['laL'], dim['laW'], brg_eff)
    else:
        fato_ring = circle_ring(dim['fatoR'], 64)
        tlof_ring = circle_ring(dim['tlofR'], 48)
        la_ring = circle_ring(dim['laR'], 64)

    for nm0, ring, kind, dz in (
            ("REF_着陸帯(FATO+SA)", la_ring, 'safety', 0.0),
            ("REF_FATO", fato_ring, 'fato', 0.05),
            ("REF_TLOF", tlof_ring, 'tlof', 0.10)):
        nm = f"{nm0}_{site.uid:03d}"
        vs = [(x, y, dz) for x, y in ring]
        upsert_mesh_object(nm, vs, [tuple(range(len(vs)))], kind, coll, zoff,
                           parent=root)
        alive.add(nm)

    # ---- 進入方向ごと ----
    dir_reports = []
    for idx, dir_p in ((1, site.dir1), (2, site.dir2)):
        if not dir_p.enabled:
            continue
        brg = effective_bearing(site, dir_p.bearing)
        ang = bearing_to_angle(brg)

        if dim['shape'] == 'rect':
            start_front, base_half = rect_support(ang, dim['laL'], dim['laW'], brg_eff)
        else:
            start_front = dim['laR']
            base_half = dim['laR'] if appr.get('innerEdgeAtSafetyArea') else D / 2.0

        sx, sy = math.cos(ang) * start_front, math.sin(ang) * start_front

        plan = direction_plan(dir_p, total_len)
        met = plan_metrics(plan)
        min_r = std.get('minCurveR', 0.0)
        if met['min_R'] is not None and min_r and met['min_R'] < min_r - 1e-6:
            warnings.append(f"進入{idx}: R={met['min_R']:.0f}m < 規格下限{min_r:.0f}m")

        cl = generate_centerline(sx, sy, ang, plan, site.cl_step)
        end_brg = angle_to_bearing(cl[-1]['ang'])
        dir_reports.append(f"進入{idx}: 曲線{met['curve_len']:.0f}m/"
                           f"旋回{met['turn_deg']:+.1f}°/終端方位{end_brg:.1f}°")

        nm = f"CL_進入{idx}_{site.uid:03d}"
        cl_v = [(q['x'], q['y'], q['dist'] * slope) for q in cl]
        upsert_mesh_object(nm, cl_v, [], 'centerline', coll, zoff,
                           edges=[(i, i + 1) for i in range(len(cl_v) - 1)],
                           parent=root)
        alive.add(nm)

        nm = f"OLS_進入表面_{idx}_{site.uid:03d}"
        v, f = build_approach_grid(cl, appr, is_night, base_half, t_half, slope)
        upsert_mesh_object(nm, v, f, 'approach', coll, zoff, parent=root)
        alive.add(nm)

        if dir_p.trans_side != 'none' and site.trans_h > 0:
            sides = {'left': [1], 'right': [-1], 'both': [-1, 1]}[dir_p.trans_side]
            for sd in sides:
                v, f, folded = build_transition_grid(
                    cl, appr, is_night, base_half, t_half, slope,
                    site.trans_slope, site.trans_h, sd, site.trans_lat_div)
                if not v:
                    continue
                lbl = "左" if sd > 0 else "右"
                nm = f"OLS_転移表面_{idx}{lbl}_{site.uid:03d}"
                upsert_mesh_object(nm, v, f, 'transition', coll, zoff, parent=root)
                alive.add(nm)
                if folded:
                    warnings.append(f"進入{idx}{lbl}: 曲線内側で転移面を0.95Rに制限")

    # ---- PSS ----
    if site.pss_apply:
        v, f = build_pss_grid(dim, site.pss_h, site.pss_slope)
        nm = f"OLS_PSS_{site.uid:03d}"
        upsert_mesh_object(nm, v, f, 'pss', coll, zoff, parent=root)
        alive.add(nm)

    # ---- 内側水平面 ----
    if site.make_inner and std['inner']['radius'] > 0:
        ring = circle_ring(std['inner']['radius'], 96)
        v = [(x, y, std['inner']['height']) for x, y in ring]
        nm = f"OLS_内側水平面_{site.uid:03d}"
        upsert_mesh_object(nm, v, [tuple(range(len(v)))], 'inner', coll, zoff,
                           parent=root)
        alive.add(nm)

    # ---- 不要になったオブジェクトを削除 ----
    for obj in list(coll.objects):
        if obj.name not in alive and (obj.get("vpols_kind") is not None
                                      or obj.get("vpols_site_root")):
            if obj.get("vpols_site_root"):
                continue
            data = obj.data
            bpy.data.objects.remove(obj, do_unlink=True)
            if isinstance(data, bpy.types.Mesh) and data.users == 0:
                bpy.data.meshes.remove(data)

    # ---- 表示用サマリ ----
    rule = {'proportional': "0.25D支配", 'minimum': "3m支配", 'manual': "手動"}[dim['saRule']]
    if dim['shape'] == 'rect':
        la_txt = f"{dim['laL']:.2f}×{dim['laW']:.2f}m"
    else:
        la_txt = f"φ{dim['laD']:.2f}m ({dim['laD']/D:.3f}D)"
    p.geo_info = (f"[{site.name}] 原点({site.origin_x:.1f}, {site.origin_y:.1f}, "
                  f"{site.origin_z:.1f}) | FATO φ{dim['fatoD']:.2f}m / "
                  f"SA幅 {dim['saMarg']:.2f}m [{rule}] → 着陸帯 {la_txt} | "
                  f"進入面 {total_len:.0f}m・末端高 {total_len*slope:.1f}m | "
                  + " | ".join(dir_reports))
    p.warn = " / ".join(warnings)
    return dim, warnings


def regenerate(p):
    """アクティブサイトを再生成する (プロパティ更新フックからの入口)。

    原点の正本は「原点Empty」。ここで props→Empty 同期を行うと、
    ユーザーが Empty を動かした直後の諸元編集で古い origin 値に
    引き戻されてしまうため、同期は行わない (読み戻しは
    get_site_root 内で Empty→props の向きにのみ行う)。
    origin_x/y/z の数値編集は専用の _origin_update が Empty へ書く。"""
    site = active_site(p)
    if site is None:
        p.geo_info = "サイトがありません。「サイト追加」で3Dカーソル位置に作成してください"
        return None, []
    return regenerate_site(p, site)


class VPOLS_OT_generate(Operator):
    bl_idname = "vpols.generate"
    bl_label = "アクティブサイトを生成 / 更新"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        dim, warnings = regenerate(p)
        if dim is None:
            self.report({'ERROR'}, p.geo_info)
            return {'CANCELLED'}
        p.summary = p.geo_info
        self.report({'WARNING'} if warnings else {'INFO'},
                    p.geo_info + ((" | " + p.warn) if warnings else ""))
        return {'FINISHED'}


class VPOLS_OT_generate_all(Operator):
    bl_idname = "vpols.generate_all"
    bl_label = "全サイトを再生成"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        n = 0
        for site in p.sites:
            regenerate_site(p, site)
            n += 1
        self.report({'INFO'}, f"{n} サイトを再生成しました")
        return {'FINISHED'}


class VPOLS_OT_check_landing_area(Operator):
    bl_idname = "vpols.check_landing_area"
    bl_label = "着陸帯の指針適合を検証"
    bl_description = "着陸帯径が 2D (D≥12m) / 1.5D+6m (D<12m) に一致するか照合する"

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        std = STANDARDS[site.standard]
        dim = compute_facility_dimensions(std, site)
        D = site.fato_d
        thr = dim['thresholdD']
        expect = 2.0 * D if D >= thr else std['fatoFactor'] * D + 2 * std['safetyOffset']
        got = dim['laD']
        ok = abs(got - expect) < 1e-6
        msg = (f"D={D:.2f}m (分岐 {thr:.1f}m) → 期待 {expect:.2f}m / 実際 {got:.2f}m "
               f"[{'一致' if ok else '不一致'}]  進入面内側縁 半幅={dim['laR']:.2f}m")
        p.summary = msg
        self.report({'INFO'} if ok else {'ERROR'}, msg)
        return {'FINISHED'}


# =============================================================================
# SECTION 7 : 照査エンジン
#   判定単位は「オブジェクト」ではなく「突出領域 (抵触箇所)」。
#     1) 物件表面をサンプリング
#     2) 各点で鉛直に制限表面高を取得し、超過量を算定
#     3) 超過点だけを点間距離 <= 分離距離 で連結成分に分解 → 1成分 = 1抵触箇所
#     4) 各領域の代表点(標点)・最大超過・概算面積を記録
# =============================================================================

SURFACE_KINDS = ('approach', 'transition', 'pss', 'inner')


def collect_ols_objects():
    """全サイトの OLS 面を収集する。複数VPの複合制限表面では、
    重なる位置は BVH レイキャストが最も低い面を返すため、
    統合してよい (支配面 = 最低面の規約が自動的に成立する)。"""
    coll = bpy.data.collections.get(COLL_SITES)
    if not coll:
        return []
    return [o for o in coll.all_objects
            if o.type == 'MESH' and o.get("vpols_kind") in SURFACE_KINDS]


def build_ols_bvh():
    objs = collect_ols_objects()
    if not objs:
        return None, None, None
    verts, polys, kinds = [], [], []
    zmin = 1e18
    for o in objs:
        kind = o.get("vpols_kind")
        mw = o.matrix_world
        base = len(verts)
        for v in o.data.vertices:
            w = mw @ v.co
            verts.append(w)
            zmin = min(zmin, w.z)
        for poly in o.data.polygons:
            idx = [base + i for i in poly.vertices]
            if len(idx) < 3:
                continue
            for k in range(1, len(idx) - 1):
                polys.append((idx[0], idx[k], idx[k + 1]))
                kinds.append(kind)
    if not polys:
        return None, None, None
    return BVHTree.FromPolygons(verts, polys, all_triangles=True, epsilon=0.0), kinds, zmin


def surface_z_at(bvh, x, y, z_start):
    """(x,y) 上で最も低い制限表面の Z。面が無ければ (None, None)"""
    hit = bvh.ray_cast(Vector((x, y, z_start)), Vector((0.0, 0.0, 1.0)))
    if hit and hit[0] is not None:
        return hit[0].z, hit[2]
    return None, None


# ---------------------------------------------------------------------------
# サンプリング
# ---------------------------------------------------------------------------

def _point_in_poly2d(x, y, ring):
    """XY 平面に投影した多角形の内外判定 (crossing number)"""
    inside = False
    n = len(ring)
    j = n - 1
    for i in range(n):
        xi, yi = ring[i]
        xj, yj = ring[j]
        if (yi > y) != (yj > y):
            xc = xi + (y - yi) * (xj - xi) / ((yj - yi) or 1e-12)
            if x < xc:
                inside = not inside
        j = i
    return inside


def sample_object_points(obj, depsgraph, p):
    """物件表面のサンプル点をワールド座標で生成。
    返値: (points, grid_step) — grid_step は面積換算に使う実効格子間隔"""
    ev = obj.evaluated_get(depsgraph)
    try:
        me = ev.to_mesh()
    except Exception:
        return [], 0.0
    if me is None:
        return [], 0.0

    mw = obj.matrix_world
    nmat = mw.to_3x3().inverted_safe().transposed()
    wverts = [mw @ v.co for v in me.vertices]
    pts = list(wverts)
    step = max(0.3, p.sample_step)

    # --- 上向き面の面積を先に集計し、格子間隔を予算内に自動調整 ---
    up_polys = []
    up_area = 0.0
    if p.sample_grid:
        for poly in me.polygons:
            n = (nmat @ poly.normal)
            if n.length < 1e-9:
                continue
            n = n.normalized()
            if n.z < 0.1:            # 水平から約6度以上立っている面は屋上とみなさない
                continue
            ring = [(wverts[i].x, wverts[i].y) for i in poly.vertices]
            if len(ring) < 3:
                continue
            xs = [q[0] for q in ring]
            ys = [q[1] for q in ring]
            a2d = 0.0
            for i in range(len(ring)):
                j = (i + 1) % len(ring)
                a2d += ring[i][0] * ring[j][1] - ring[j][0] * ring[i][1]
            a2d = abs(a2d) * 0.5
            up_area += a2d
            c = mw @ poly.center
            up_polys.append((ring, min(xs), max(xs), min(ys), max(ys), n, c))
        budget = max(200, p.max_samples_per_obj - len(pts))
        if up_area > 0 and up_area / (step * step) > budget:
            step = math.sqrt(up_area / budget)

    # --- 辺上 ---
    if p.sample_edge:
        for e in me.edges:
            a, b = wverts[e.vertices[0]], wverts[e.vertices[1]]
            n = int((b - a).length // step)
            for i in range(1, min(n, 200) + 1):
                pts.append(a.lerp(b, i / (min(n, 200) + 1)))

    # --- 上向き面の格子 ---
    for ring, x0, x1, y0, y1, n, c in up_polys:
        nx = max(1, int((x1 - x0) / step))
        ny = max(1, int((y1 - y0) / step))
        for ix in range(nx + 1):
            gx = x0 + (ix + 0.5) * (x1 - x0) / (nx + 1)
            for iy in range(ny + 1):
                gy = y0 + (iy + 0.5) * (y1 - y0) / (ny + 1)
                if not _point_in_poly2d(gx, gy, ring):
                    continue
                gz = c.z - (n.x * (gx - c.x) + n.y * (gy - c.y)) / n.z
                pts.append(Vector((gx, gy, gz)))

    # --- 面中心 ---
    if p.sample_face:
        for poly in me.polygons:
            pts.append(mw @ poly.center)

    ev.to_mesh_clear()
    return pts, step


# ---------------------------------------------------------------------------
# 突出点の連結成分分解
# ---------------------------------------------------------------------------

def cluster_points(points, link, area_cell):
    """突出点を「点間距離 <= link なら同一領域」の規則で連結成分に分解する。

    グリッドは近傍探索の高速化にのみ使い、結合判定は実距離で行う。
    (セル単位の8近傍判定だと実効結合距離が link〜2.8*link に広がり、
     パラメータの意味が曖昧になるため)

    返値: [(indices, area_m2), ...]
    """
    n = len(points)
    parent = list(range(n))

    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[rb] = ra

    cells = {}
    for i, q in enumerate(points):
        cells.setdefault((int(math.floor(q['x'] / link)),
                          int(math.floor(q['y'] / link))), []).append(i)

    link2 = link * link
    for (cx, cy), idxs in cells.items():
        for dx in (0, 1):
            for dy in (-1, 0, 1):
                if dx == 0 and dy < 0:
                    continue                     # 重複走査を回避
                nb = cells.get((cx + dx, cy + dy))
                if not nb:
                    continue
                same = (dx == 0 and dy == 0)
                for a_i, i in enumerate(idxs):
                    qi = points[i]
                    for j in (nb[a_i + 1:] if same else nb):
                        qj = points[j]
                        ddx = qi['x'] - qj['x']
                        ddy = qi['y'] - qj['y']
                        if ddx * ddx + ddy * ddy <= link2:
                            union(i, j)

    groups = {}
    for i in range(n):
        groups.setdefault(find(i), []).append(i)

    out = []
    ac = max(0.3, area_cell)
    for comp in groups.values():
        occupied = {(int(math.floor(points[i]['x'] / ac)),
                     int(math.floor(points[i]['y'] / ac))) for i in comp}
        out.append((comp, len(occupied) * ac * ac))
    return out


class VPOLS_OT_analyze(Operator):
    bl_idname = "vpols.analyze"
    bl_label = "抵触照査を実行"
    bl_description = "制限表面から突出している領域を全て抽出し、領域ごとに代表点と超過量を算定する"

    def execute(self, context):
        p = context.scene.vpols
        if not p.target_coll:
            self.report({'ERROR'}, "都市モデルのコレクションを指定してください")
            return {'CANCELLED'}
        bvh, kinds, zmin = build_ols_bvh()
        if bvh is None:
            self.report({'ERROR'}, "OLS 面がありません。先に生成してください")
            return {'CANCELLED'}

        z_start = zmin - 2000.0
        depsgraph = context.evaluated_depsgraph_get()
        p.results.clear()

        all_spots = []
        cloud_verts = []
        n_obj_tested = 0
        n_obj_conflict = 0
        total_tested = 0
        best_clear_all = 1e18

        for obj in p.target_coll.all_objects:
            if obj.type != 'MESH' or obj.hide_viewport:
                continue
            pts, step = sample_object_points(obj, depsgraph, p)
            if not pts:
                continue

            over = []            # 突出点のみ
            tested = 0
            obj_min_clear = 1e18
            for w in pts:
                sz, fi = surface_z_at(bvh, w.x, w.y, z_start)
                if sz is None:
                    continue
                tested += 1
                clear = sz - w.z
                if clear < obj_min_clear:
                    obj_min_clear = clear
                exc = -clear
                if exc > p.threshold:
                    over.append(dict(x=w.x, y=w.y, z=w.z, exc=exc, sz=sz,
                                     kind=(kinds[fi] if fi is not None and fi < len(kinds) else '')))
            if tested == 0:
                continue
            n_obj_tested += 1
            total_tested += tested
            best_clear_all = min(best_clear_all, obj_min_clear)
            if not over:
                continue
            n_obj_conflict += 1

            # --- 突出点を空間的に分離 ---
            comps = cluster_points(over, p.spot_link, step)
            spots = []
            for comp, area in comps:
                if len(comp) < p.min_spot_points:
                    continue
                sub = [over[i] for i in comp]
                by_exc = max(sub, key=lambda q: q['exc'])
                by_hgt = max(sub, key=lambda q: q['z'])
                rep = by_hgt if p.rep_mode == 'HEIGHT' else by_exc
                xs = [q['x'] for q in sub]
                ys = [q['y'] for q in sub]
                spots.append(dict(
                    obj=obj.name, rep=rep, max_exc=by_exc['exc'], peak_z=by_hgt['z'],
                    n=len(sub), area=area,
                    ext_x=max(xs) - min(xs), ext_y=max(ys) - min(ys),
                    obj_min_clear=obj_min_clear))
            spots.sort(key=lambda s: -s['max_exc'])
            for i, sp in enumerate(spots, 1):
                sp['spot_no'] = i
                sp['spots_in_obj'] = len(spots)
            all_spots.extend(spots)

            if p.make_point_cloud:
                cloud_verts.extend((q['x'], q['y'], q['z']) for q in over)
            obj.color = SURFACE_COLORS['conflict']

        # --- 全抵触箇所を超過量で通し番号付け ---
        all_spots.sort(key=lambda s: -s['max_exc'])
        for i, sp in enumerate(all_spots, 1):
            it = p.results.add()
            it.rank = i
            it.obj_name = sp['obj']
            it.spot_no = sp['spot_no']
            it.spots_in_obj = sp['spots_in_obj']
            it.max_excess = sp['max_exc']
            it.peak_z = sp['peak_z']
            it.surface = sp['rep']['kind']
            it.surf_z = sp['rep']['sz']
            it.rx, it.ry, it.rz = sp['rep']['x'], sp['rep']['y'], sp['rep']['z']
            it.n_points = sp['n']
            it.area_m2 = sp['area']
            it.ext_x, it.ext_y = sp['ext_x'], sp['ext_y']
            it.obj_min_clearance = sp['obj_min_clear']

        # --- マーカー / 点群 ---
        mark = bpy.data.collections.get(COLL_MARK)
        if mark:
            clear_collection(mark)
        if (p.make_markers or p.make_point_cloud) and all_spots:
            mark = get_collection(COLL_MARK)
            if p.make_markers:
                for it in p.results:
                    em = bpy.data.objects.new(
                        f"C{it.rank:03d}_{it.obj_name[:24]}_S{it.spot_no}_+{it.max_excess:.1f}m", None)
                    em.empty_display_type = 'SPHERE'
                    em.empty_display_size = 3.0
                    em.location = (it.rx, it.ry, it.rz)
                    em["vpols_excess"] = it.max_excess
                    em["vpols_surface"] = it.surface
                    mark.objects.link(em)
            if p.make_point_cloud and cloud_verts:
                upsert_mesh_object("突出サンプル点群", cloud_verts, [], 'conflict', mark, 0.0)

        multi = sum(1 for s in all_spots if s['spots_in_obj'] > 1)
        p.summary = (f"照査 {n_obj_tested}物件 / 抵触 {n_obj_conflict}物件 → "
                     f"抵触箇所 {len(all_spots)}件 (複数箇所を持つ物件由来 {multi}件) / "
                     f"{total_tested:,}点"
                     + (f" / 最大超過 {all_spots[0]['max_exc']:.2f}m "
                        f"({all_spots[0]['obj']})" if all_spots else
                        f" / 最小垂直離隔 {best_clear_all:+.2f}m"))
        self.report({'INFO'}, p.summary)
        return {'FINISHED'}


class VPOLS_OT_probe(Operator):
    bl_idname = "vpols.probe"
    bl_label = "3Dカーソル位置の垂直離隔を測定"

    def execute(self, context):
        p = context.scene.vpols
        bvh, kinds, zmin = build_ols_bvh()
        if bvh is None:
            self.report({'ERROR'}, "OLS 面がありません")
            return {'CANCELLED'}
        c = context.scene.cursor.location
        sz, fi = surface_z_at(bvh, c.x, c.y, zmin - 2000.0)
        if sz is None:
            p.summary = "カーソル位置は制限表面の水平範囲外です"
        else:
            kind = kinds[fi] if fi is not None and fi < len(kinds) else '?'
            clear = sz - c.z
            p.summary = (f"[{'抵触' if clear < 0 else '適合'}] 支配面={kind} / "
                         f"面高 {sz:.2f}m / 点高 {c.z:.2f}m / 垂直離隔 {clear:+.2f}m")
        self.report({'INFO'}, p.summary)
        return {'FINISHED'}


class VPOLS_OT_select_result(Operator):
    bl_idname = "vpols.select_result"
    bl_label = "該当箇所へ移動"

    def execute(self, context):
        p = context.scene.vpols
        if not (0 <= p.result_index < len(p.results)):
            return {'CANCELLED'}
        it = p.results[p.result_index]
        obj = bpy.data.objects.get(it.obj_name)
        if obj:
            bpy.ops.object.select_all(action='DESELECT')
            obj.select_set(True)
            context.view_layer.objects.active = obj
        context.scene.cursor.location = (it.rx, it.ry, it.rz)
        return {'FINISHED'}


# =============================================================================
# SECTION 8 : 抵触体積の切り出し (実験的)
# =============================================================================

class VPOLS_OT_extract(Operator):
    bl_idname = "vpols.extract"
    bl_label = "抵触部を切り出す"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        src = collect_ols_objects()
        if not src:
            self.report({'ERROR'}, "OLS 面がありません")
            return {'CANCELLED'}
        targets = [o for o in context.selected_objects
                   if o.type == 'MESH' and o.get("vpols_kind") is None]
        if not targets:
            self.report({'ERROR'}, "都市モデルのオブジェクトを選択してください")
            return {'CANCELLED'}

        bm = bmesh.new()
        for o in src:
            me = o.data.copy()
            me.transform(o.matrix_world)
            bm.from_mesh(me)
            bpy.data.meshes.remove(me)
        res = bmesh.ops.extrude_face_region(bm, geom=bm.faces[:], use_keep_orig=True)
        new_verts = [g for g in res['geom'] if isinstance(g, bmesh.types.BMVert)]
        bmesh.ops.translate(bm, verts=new_verts, vec=Vector((0.0, 0.0, p.slab_thickness)))
        bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
        slab_mesh = bpy.data.meshes.new("VPOLS_禁止スラブ")
        bm.to_mesh(slab_mesh)
        bm.free()
        slab = bpy.data.objects.new("VPOLS_禁止スラブ", slab_mesh)
        context.scene.collection.objects.link(slab)

        cut_coll = get_collection(COLL_CUT)
        made, total_vol = 0, 0.0
        for t in targets:
            dup = t.copy()
            dup.data = t.data.copy()
            dup.name = f"CUT_{t.name}"
            cut_coll.objects.link(dup)
            mod = dup.modifiers.new("VPOLS_BOOL", 'BOOLEAN')
            mod.operation, mod.object, mod.solver = 'INTERSECT', slab, 'EXACT'
            try:
                mod.use_self = True
            except Exception:
                pass
            with context.temp_override(object=dup, active_object=dup,
                                       selected_objects=[dup]):
                try:
                    bpy.ops.object.modifier_apply(modifier=mod.name)
                except Exception as e:
                    self.report({'WARNING'}, f"{t.name}: ブーリアン失敗 ({e})")
                    bpy.data.objects.remove(dup, do_unlink=True)
                    continue
            if len(dup.data.polygons) == 0:
                bpy.data.objects.remove(dup, do_unlink=True)
                continue
            bmv = bmesh.new()
            bmv.from_mesh(dup.data)
            bmv.transform(dup.matrix_world)
            vol = bmv.calc_volume(signed=False)
            bmv.free()
            dup["vpols_penetration_volume_m3"] = vol
            dup.color = SURFACE_COLORS['conflict']
            total_vol += vol
            made += 1

        bpy.data.objects.remove(slab, do_unlink=True)
        p.summary = f"抵触部 {made} 件 / 合計体積 {total_vol:,.1f} m³"
        self.report({'INFO'}, p.summary)
        return {'FINISHED'}


# =============================================================================
# SECTION 9 : 入出力
# =============================================================================

class VPOLS_OT_export_csv(Operator, ExportHelper):
    bl_idname = "vpols.export_csv"
    bl_label = "照査結果をCSV出力"
    filename_ext = ".csv"
    filter_glob: StringProperty(default="*.csv", options={'HIDDEN'})

    def execute(self, context):
        p = context.scene.vpols
        path = self.filepath if self.filepath.lower().endswith(".csv") else self.filepath + ".csv"
        with open(path, "w", newline="", encoding="utf-8-sig") as fp:
            w = csv.writer(fp)
            w.writerow(["No", "オブジェクト", "箇所番号", "同一物件の箇所数", "支配面",
                        "最大超過[m]", "領域内最高点Z[m]", "代表点の面高[m]",
                        "代表点X[m]", "代表点Y[m]", "代表点Z[m]",
                        "突出概算面積[m2]", "領域幅X[m]", "領域幅Y[m]",
                        "突出サンプル点数", "物件の最小垂直離隔[m]"])
            for it in p.results:
                w.writerow([it.rank, it.obj_name, it.spot_no, it.spots_in_obj, it.surface,
                            f"{it.max_excess:.3f}", f"{it.peak_z:.3f}", f"{it.surf_z:.3f}",
                            f"{it.rx:.3f}", f"{it.ry:.3f}", f"{it.rz:.3f}",
                            f"{it.area_m2:.1f}", f"{it.ext_x:.2f}", f"{it.ext_y:.2f}",
                            it.n_points, f"{it.obj_min_clearance:.3f}"])
        self.report({'INFO'}, f"CSV出力: {os.path.basename(path)} ({len(p.results)}件)")
        return {'FINISHED'}


class VPOLS_OT_site_add(Operator):
    bl_idname = "vpols.site_add"
    bl_label = "サイト追加 (3Dカーソル位置)"
    bl_description = "3Dカーソルの位置を原点として新しい制限表面サイトを作成する"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        c = context.scene.cursor.location
        p.site_seq += 1
        site = p.sites.add()
        site.uid = p.site_seq
        site.name = f"VP-{p.site_seq}"
        site["origin_x"], site["origin_y"], site["origin_z"] = c.x, c.y, c.z
        p.site_index = len(p.sites) - 1
        regenerate_site(p, site)
        self.report({'INFO'}, f"{site.name} を ({c.x:.1f}, {c.y:.1f}, {c.z:.1f}) に作成しました")
        return {'FINISHED'}


class VPOLS_OT_site_remove(Operator):
    bl_idname = "vpols.site_remove"
    bl_label = "サイト削除"
    bl_description = "アクティブサイトとその制限表面オブジェクトを削除する"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            return {'CANCELLED'}
        cname = site_coll_name(site)
        coll = bpy.data.collections.get(cname)
        if coll:
            clear_collection(coll)
            try:
                bpy.data.collections.remove(coll)
            except Exception:
                pass
        name = site.name
        p.sites.remove(p.site_index)
        p.site_index = min(p.site_index, len(p.sites) - 1)
        self.report({'INFO'}, f"{name} を削除しました")
        return {'FINISHED'}


class VPOLS_OT_site_duplicate(Operator):
    bl_idname = "vpols.site_duplicate"
    bl_label = "サイト複製 (3Dカーソル位置へ)"
    bl_description = "アクティブサイトの諸元をコピーし、3Dカーソル位置に新サイトを作成する"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        src = active_site(p)
        if src is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        c = context.scene.cursor.location
        p.site_seq += 1
        dst = p.sites.add()
        # PropertyGroup の一括コピー (uid/name/origin は上書き)
        _copy_pg(src, dst)
        dst.uid = p.site_seq
        dst.name = f"{src.name}-copy"
        dst["origin_x"], dst["origin_y"], dst["origin_z"] = c.x, c.y, c.z
        p.site_index = len(p.sites) - 1
        regenerate_site(p, dst)
        self.report({'INFO'}, f"{dst.name} を作成しました")
        return {'FINISHED'}


def _copy_pg(src, dst):
    """PropertyGroup を再帰コピーする (PointerProperty の入れ子対応)。"""
    for key in src.__class__.__annotations__.keys():
        try:
            v = getattr(src, key)
        except Exception:
            continue
        if isinstance(v, PropertyGroup):
            _copy_pg(v, getattr(dst, key))
        else:
            try:
                setattr(dst, key, v)
            except Exception:
                pass


class VPOLS_OT_site_origin_cursor(Operator):
    bl_idname = "vpols.site_origin_cursor"
    bl_label = "原点を3Dカーソルへ移動"
    bl_description = "アクティブサイトの原点Emptyを3Dカーソル位置へ移動する (表面一式が追従)"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        c = context.scene.cursor.location
        site["origin_x"], site["origin_y"], site["origin_z"] = c.x, c.y, c.z
        sync_root_from_props(site)
        regenerate_site(p, site)
        self.report({'INFO'}, f"{site.name} の原点を ({c.x:.1f}, {c.y:.1f}, {c.z:.1f}) へ移動")
        return {'FINISHED'}


class VPOLS_UL_sites(UIList):
    def draw_item(self, ctx, layout, data, item, icon, active_data, active_prop, index):
        row = layout.row(align=True)
        row.prop(item, "name", text="", emboss=False)
        row.label(text=f"({item.origin_x:.0f}, {item.origin_y:.0f}, {item.origin_z:.0f})")


# -----------------------------------------------------------------------------
# HTML ⇄ Blender 共通 OLS 交換 (vp-ols-exchange/1.0)
# -----------------------------------------------------------------------------
VPOLS_EXCHANGE_SCHEMA = "vp-ols-exchange/1.0"

def _fnum(v, default=0.0):
    try:
        x = float(v)
        return x if math.isfinite(x) else float(default)
    except Exception:
        return float(default)


def _direction_to_exchange(dp):
    segs = []
    for i in range(1, 7):
        typ = getattr(dp, f"seg{i}_type")
        segs.append({
            "index": i,
            "type": typ,
            "lengthM": float(getattr(dp, f"seg{i}_len")),
            "radiusM": float(getattr(dp, f"seg{i}_r")),
        })
    return {
        "enabled": bool(dp.enabled),
        "bearingDeg": float(dp.bearing),
        "transitionSide": dp.trans_side,
        "curveSignFactor": -1 if getattr(dp, 'exchange_curve_sign', 1.0) < 0.0 else 1,
        "useSegments": bool(dp.use_segments),
        "initialStraightM": float(dp.straight0),
        "radiusM": float(dp.radius),
        "turn": dp.turn,
        "turnAngleDeg": float(dp.turn_deg),
        "curves": int(dp.curves),
        "interStraightM": float(dp.inter_s),
        "tailMode": dp.tail_mode,
        "tailRadiusM": float(dp.tail_r),
        "segments": segs,
    }


def _apply_exchange_direction(dp, d):
    d = d or {}
    dp.enabled = bool(d.get("enabled", True))
    dp.bearing = _fnum(d.get("bearingDeg"), 0.0) % 360.0
    side = d.get("transitionSide", "both")
    dp.trans_side = side if side in {'both', 'left', 'right', 'none'} else 'both'
    # schema 1.2+: HTML Viewerは -1、Blenderネイティブは +1。
    # 未指定の旧交換JSONは従来Blender互換として +1 を採用する。
    cs = _fnum(d.get("curveSignFactor"), 1.0)
    dp.exchange_curve_sign = -1.0 if cs < 0.0 else 1.0
    dp.use_segments = bool(d.get("useSegments", False))
    dp.straight0 = max(0.0, _fnum(d.get("initialStraightM"), 300.0))
    dp.radius = max(1.0, _fnum(d.get("radiusM"), 270.0))
    turn = d.get("turn", "none")
    dp.turn = turn if turn in {'none', 'left', 'right'} else 'none'
    dp.turn_deg = max(0.0, min(36000.0, _fnum(d.get("turnAngleDeg"), 30.0)))
    dp.curves = max(1, min(4, int(round(_fnum(d.get("curves"), 1)))))
    dp.inter_s = max(0.0, _fnum(d.get("interStraightM"), 150.0))
    tail = d.get("tailMode", "straight")
    dp.tail_mode = tail if tail in {'straight', 'continue', 'left', 'right'} else 'straight'
    dp.tail_r = max(1.0, _fnum(d.get("tailRadiusM"), 270.0))
    by_idx = {int(_fnum(s.get("index"), 0)): s for s in (d.get("segments") or [])}
    for i in range(1, 7):
        s = by_idx.get(i, {})
        typ = s.get("type", 'skip')
        if typ == 'line':
            typ = 'straight'
        if typ not in {'skip', 'straight', 'left', 'right'}:
            typ = 'skip'
        setattr(dp, f"seg{i}_type", typ)
        setattr(dp, f"seg{i}_len", max(0.0, _fnum(s.get("lengthM"), 0.0)))
        setattr(dp, f"seg{i}_r", max(1.0, _fnum(s.get("radiusM"), 270.0)))


def _sync_exchange_ref_from_dict(p, ref):
    h = (ref or {}).get("horizontal") or {}
    p.exchange_ref_valid = bool(h)
    p.exchange_crs_type = str(h.get("type") or (ref or {}).get("type") or "jgd2011-tm")
    p.exchange_datum = str(h.get("datum") or "JGD2011")
    p.exchange_epsg = int(_fnum(h.get("epsg"), 0))
    p.exchange_zone = int(_fnum(h.get("zone"), 0))
    p.exchange_origin_lat = _fnum(h.get("originLat"), 0.0)
    p.exchange_origin_lng = _fnum(h.get("originLng"), 0.0)
    p.exchange_origin_e = _fnum(h.get("originE"), 0.0)
    p.exchange_origin_n = _fnum(h.get("originN"), 0.0)
    p.exchange_convergence = _fnum(h.get("convergence"), 0.0)
    p.exchange_vertical_datum = str(((ref or {}).get("vertical") or {}).get("datum") or "TP/AMSL")


def _exchange_ref_to_dict(p):
    return {
        "type": "local-enu-on-jgd2011-tm",
        "unit": "m",
        "axes": "X_east_Y_north_Z_up",
        "horizontal": {
            "type": p.exchange_crs_type or "jgd2011-tm",
            "datum": p.exchange_datum or "JGD2011",
            "ellipsoid": "GRS80",
            "epsg": int(p.exchange_epsg) if p.exchange_epsg else None,
            "zone": int(p.exchange_zone) if p.exchange_zone else None,
            "unit": "m",
            "origin": "shared_reference",
            "axes": "X_east_Y_north",
            "originLat": float(p.exchange_origin_lat),
            "originLng": float(p.exchange_origin_lng),
            "originE": float(p.exchange_origin_e),
            "originN": float(p.exchange_origin_n),
            "convergence": float(p.exchange_convergence),
        },
        "vertical": {"datum": p.exchange_vertical_datum or "TP/AMSL", "unit": "m", "positive": "up"},
        "blenderFrame": {"worldAxes": "X_east_Y_north_Z_up", "worldOriginLocalENU": [0, 0, 0], "rotationDeg": 0, "scale": 1},
    }


def _site_to_exchange(p, site):
    coll = get_site_collection(site)
    root = get_site_root(site, coll)  # Emptyの現在位置をプロパティへ読み戻す
    loc = root.location.copy()
    # 共通交換値はローカル座標軸（グリッド北）基準。
    # Blenderで真方位入力モードを使っている場合は、実際に生成されたグリッド方位へ変換する。
    fato_brg_exchange = effective_bearing(site, site.fato_bearing) if site.use_grid_north else site.fato_bearing
    directions_exchange = [_direction_to_exchange(site.dir1), _direction_to_exchange(site.dir2)]
    if site.use_grid_north:
        directions_exchange[0]["bearingDeg"] = effective_bearing(site, site.dir1.bearing)
        directions_exchange[1]["bearingDeg"] = effective_bearing(site, site.dir2.bearing)
    return {
        "id": site.get("exchange_id", f"SITE-{site.uid:03d}"),
        "role": site.get("exchange_role", "secondary"),
        "opsShare": float(site.get("exchange_ops_share", 1.0)),
        "name": site.name,
        "enabled": True,
        "placement": {"localENU": [float(loc.x), float(loc.y), float(loc.z)], "axes": "X_east_Y_north_Z_up"},
        "geographic": {
            "lat": float(site.geo_lat), "lng": float(site.geo_lon), "datum": "JGD2011",
            "siteElevationAMSL": float(site.site_elev), "padHeightM": float(site.pad_h),
            "olsBaseOffsetM": float(site.ols_base_h), "source": "Blender VPOLS_Site"
        },
        "facility": {
            "standard": site.standard,
            "D": float(site.fato_d),
            "afmRequired": float(site.afm_required),
            "shape": site.fato_shape,
            "bearingDeg": float(fato_brg_exchange),
            "rectFatoLengthM": float(site.rect_fato_l),
            "rectFatoWidthM": float(site.rect_fato_w),
            "rectTlofLengthM": float(site.rect_tlof_l),
            "rectTlofWidthM": float(site.rect_tlof_w),
            "safetyMarginManualM": float(site.sa_margin_manual),
            "elevated": bool(site.elevated),
            "operationCategory": "vfr_night" if site.night else "vfr_day",
        },
        "surfaces": {
            "approachSlope": float(site.slope),
            "transitionHeightM": float(site.trans_h),
            "transitionSlope": float(site.trans_slope),
            "pssEnabled": bool(site.pss_apply),
            "pssHeightM": float(site.pss_h),
            "pssSlope": float(site.pss_slope),
            "innerHorizontalEnabled": bool(site.make_inner),
            "zDatum": site.z_datum,
            "siteElevationAMSL": float(site.site_elev),
            "padHeightM": float(site.pad_h),
            "olsBaseOffsetM": float(site.ols_base_h),
            "useGridNorth": False,
            "convergenceDeg": float(site.convergence),
            "centerlineStepM": float(site.cl_step),
            "transitionLateralDivisions": int(site.trans_lat_div),
        },
        "directions": directions_exchange,
    }


def _clear_exchange_sites(p):
    for site in list(p.sites):
        coll = bpy.data.collections.get(site_coll_name(site))
        if coll:
            clear_collection(coll)
            try:
                bpy.data.collections.remove(coll)
            except Exception:
                pass
    p.sites.clear()
    p.site_index = 0


def _apply_exchange_site(p, rec):
    p.site_seq += 1
    site = p.sites.add()
    site.uid = p.site_seq
    site["exchange_id"] = str(rec.get("id") or f"SITE-{site.uid:03d}")
    site["exchange_role"] = str(rec.get("role") or "secondary")
    site["exchange_ops_share"] = _fnum(rec.get("opsShare"), 1.0)
    site.name = str(rec.get("name") or rec.get("id") or f"VP-{p.site_seq}")
    pl = ((rec.get("placement") or {}).get("localENU") or [0, 0, 0])
    site["origin_x"] = _fnum(pl[0] if len(pl) > 0 else 0.0)
    site["origin_y"] = _fnum(pl[1] if len(pl) > 1 else 0.0)
    site["origin_z"] = _fnum(pl[2] if len(pl) > 2 else 0.0)
    # v0.8.4: HTML交換JSONに明示されたPLATEAU取得中心をサイトへ反映。
    # 旧交換ファイルでは共有基準緯度経度へフォールバックする。
    g = rec.get("geographic") or {}
    site.geo_lat = _fnum(g.get("lat"), p.exchange_origin_lat if p.exchange_ref_valid else site.geo_lat)
    site.geo_lon = _fnum(g.get("lng"), p.exchange_origin_lng if p.exchange_ref_valid else site.geo_lon)
    f = rec.get("facility") or {}
    s = rec.get("surfaces") or {}
    if f.get("standard") in STANDARDS:
        site.standard = f["standard"]
    site.fato_d = max(1.0, _fnum(f.get("D"), 15.0))
    site.afm_required = max(0.0, _fnum(f.get("afmRequired"), 0.0))
    site.fato_shape = 'rect' if f.get("shape") == 'rect' else 'circle'
    site.fato_bearing = _fnum(f.get("bearingDeg"), 0.0) % 360.0
    site.rect_fato_l = max(0.0, _fnum(f.get("rectFatoLengthM"), 0.0))
    site.rect_fato_w = max(0.0, _fnum(f.get("rectFatoWidthM"), 0.0))
    site.rect_tlof_l = max(0.0, _fnum(f.get("rectTlofLengthM"), 0.0))
    site.rect_tlof_w = max(0.0, _fnum(f.get("rectTlofWidthM"), 0.0))
    site.sa_margin_manual = max(0.0, _fnum(f.get("safetyMarginManualM"), 0.0))
    site.elevated = bool(f.get("elevated", False))
    site.night = f.get("operationCategory") == 'vfr_night'
    site.slope = max(0.001, _fnum(s.get("approachSlope"), 0.125))
    site.trans_h = max(0.0, _fnum(s.get("transitionHeightM"), 45.0))
    site.trans_slope = max(0.01, _fnum(s.get("transitionSlope"), 0.5))
    site.pss_apply = bool(s.get("pssEnabled", False))
    site.pss_h = max(0.0, _fnum(s.get("pssHeightM"), 10.0))
    site.pss_slope = max(0.01, _fnum(s.get("pssSlope"), 1.0))
    site.make_inner = bool(s.get("innerHorizontalEnabled", False))
    zd = s.get("zDatum", 'AMSL')
    site.z_datum = zd if zd in {'AMSL', 'BASE'} else 'AMSL'
    site.site_elev = _fnum(s.get("siteElevationAMSL"), 0.0)
    site.pad_h = _fnum(s.get("padHeightM"), 0.0)
    site.ols_base_h = _fnum(s.get("olsBaseOffsetM"), 0.0)
    site.use_grid_north = bool(s.get("useGridNorth", False))
    site.convergence = _fnum(s.get("convergenceDeg"), p.exchange_convergence)
    site.cl_step = max(1.0, min(100.0, _fnum(s.get("centerlineStepM"), 10.0)))
    site.trans_lat_div = max(2, min(24, int(round(_fnum(s.get("transitionLateralDivisions"), 6)))))
    dirs = rec.get("directions") or []
    _apply_exchange_direction(site.dir1, dirs[0] if len(dirs) > 0 else {"enabled": True, "bearingDeg": 0})
    _apply_exchange_direction(site.dir2, dirs[1] if len(dirs) > 1 else {"enabled": False, "bearingDeg": 180})
    return site


class VPOLS_OT_export_exchange(Operator, ExportHelper):
    bl_idname = "vpols.export_exchange"
    bl_label = "HTML連携JSONを出力"
    filename_ext = ".vpols.json"
    filter_glob: StringProperty(default="*.vpols.json;*.json", options={'HIDDEN'})

    def execute(self, context):
        p = context.scene.vpols
        if not p.sites:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        payload = {
            "schema": VPOLS_EXCHANGE_SCHEMA,
            "schemaVersion": "1.2",
            "source": {"tool": "VP-OLS Screening for Blender", "version": "0.8.7"},
            "coordinateReference": _exchange_ref_to_dict(p),
            "plateauReference": {
                "datum": "JGD2011", "apiCoordinateOrder": "lng,lat",
                "queryCenter": {"lat": float(p.sites[0].geo_lat), "lng": float(p.sites[0].geo_lon),
                                "siteElevationAMSL": float(p.sites[0].site_elev)},
                "expectedCityGMLCRS": ["EPSG:6697", "EPSG:6668"],
                "recommendedTypes": ["bldg", "dem"]
            },
            "sites": [_site_to_exchange(p, s) for s in p.sites] + json.loads(p.get("exchange_disabled_sites", "[]")),
            "notes": ["parametric exchange", "root Empty location exported as placement.localENU",
                      "site.geographic stores PLATEAU query coordinates"],
        }
        path = self.filepath
        if not path.lower().endswith('.json'):
            path += '.vpols.json'
        try:
            with open(path, 'w', encoding='utf-8') as fp:
                json.dump(payload, fp, ensure_ascii=False, indent=2)
        except Exception as e:
            self.report({'ERROR'}, f"出力失敗: {e}")
            return {'CANCELLED'}
        self.report({'INFO'}, f"HTML連携JSON出力: {len(payload['sites'])}サイト")
        return {'FINISHED'}


class VPOLS_OT_import_exchange(Operator, ImportHelper):
    bl_idname = "vpols.import_exchange"
    bl_label = "HTML連携JSONを読み込む"
    filename_ext = ".json"
    filter_glob: StringProperty(default="*.vpols.json;*.json", options={'HIDDEN'})
    replace_existing: BoolProperty(name="既存サイトを置換", default=True)

    def execute(self, context):
        global _UPDATING
        p = context.scene.vpols
        try:
            with open(self.filepath, 'r', encoding='utf-8') as fp:
                d = json.load(fp)
        except Exception as e:
            self.report({'ERROR'}, f"読込失敗: {e}")
            return {'CANCELLED'}
        if d.get("schema") != VPOLS_EXCHANGE_SCHEMA:
            self.report({'ERROR'}, f"未対応スキーマ: {d.get('schema') or 'なし'}")
            return {'CANCELLED'}
        sites = d.get("sites") or []
        if not sites:
            self.report({'ERROR'}, "sitesが空です")
            return {'CANCELLED'}

        # 旧HTML Viewer (schema 1.0/1.1) は curveSignFactor を出力していなかったが、
        # Blenderとは曲率符号規約が逆だった。旧JSONも自動補正して再利用できるようにする。
        src_tool = str((d.get("source") or {}).get("tool") or '')
        if src_tool == 'VP-OLS Viewer':
            for rec in sites:
                for dd in (rec.get("directions") or []):
                    if isinstance(dd, dict) and "curveSignFactor" not in dd:
                        dd["curveSignFactor"] = -1

        _UPDATING = True
        try:
            _sync_exchange_ref_from_dict(p, d.get("coordinateReference") or {})
            # v1.1ではPLATEAU用queryCenterも保持。水平基準が無い交換データの救済に使用。
            pr = (d.get("plateauReference") or {}).get("queryCenter") or {}
            if (not p.exchange_ref_valid or (abs(p.exchange_origin_lat) < 1e-12 and abs(p.exchange_origin_lng) < 1e-12)) and pr:
                p.exchange_ref_valid = True
                p.exchange_origin_lat = _fnum(pr.get("lat"), p.exchange_origin_lat)
                p.exchange_origin_lng = _fnum(pr.get("lng"), p.exchange_origin_lng)
            if self.replace_existing:
                _clear_exchange_sites(p)
            disabled = [] if self.replace_existing else json.loads(p.get("exchange_disabled_sites", "[]"))
            disabled.extend(rec for rec in sites if rec.get("enabled", True) is False)
            p["exchange_disabled_sites"] = json.dumps(disabled, ensure_ascii=False)
            made = []
            for rec in sites:
                if rec.get("enabled", True) is False:
                    continue
                made.append(_apply_exchange_site(p, rec))
            if not made:
                raise ValueError("有効なサイトがありません")
            p.site_index = max(0, len(p.sites) - len(made))
        except Exception as e:
            self.report({'ERROR'}, f"交換データ適用失敗: {e}")
            return {'CANCELLED'}
        finally:
            _UPDATING = False
        for site in made:
            regenerate_site(p, site)
        p.site_index = 0
        p.summary = (f"HTML連携読込: {len(made)}サイト / EPSG:{p.exchange_epsg or '未設定'} / "
                     f"基準({p.exchange_origin_lat:.7f}, {p.exchange_origin_lng:.7f})")
        self.report({'INFO'}, p.summary)
        return {'FINISHED'}


class VPOLS_OT_import_vpsite(Operator, ImportHelper):
    bl_idname = "vpols.import_vpsite"
    bl_label = ".vpsite.json を読み込む"
    filename_ext = ".json"
    filter_glob: StringProperty(default="*.json", options={'HIDDEN'})

    def execute(self, context):
        global _UPDATING
        p = context.scene.vpols
        try:
            with open(self.filepath, "r", encoding="utf-8") as fp:
                d = json.load(fp)
        except Exception as e:
            self.report({'ERROR'}, f"読み込み失敗: {e}")
            return {'CANCELLED'}

        crs, fato, ols = d.get("crs") or {}, d.get("fato") or {}, d.get("ols") or {}
        site = active_site(p)
        if site is None:
            c = context.scene.cursor.location
            p.site_seq += 1
            site = p.sites.add()
            site.uid = p.site_seq
            site.name = f"VP-{p.site_seq}"
            site["origin_x"], site["origin_y"], site["origin_z"] = c.x, c.y, c.z
            p.site_index = len(p.sites) - 1
        _UPDATING = True                     # 取込中は再生成を抑止
        try:
            if fato.get("standard") in STANDARDS:
                site.standard = fato["standard"]
            if fato.get("D"):
                site.fato_d = float(fato["D"])
            site.afm_required = float(fato.get("afmRequired") or 0.0)
            site.fato_shape = 'rect' if fato.get("shape") == 'rect' else 'circle'
            site.fato_bearing = float(fato.get("bearing") or 0.0)
            site.rect_fato_l = float(fato.get("rectFatoL") or 0.0)
            site.rect_fato_w = float(fato.get("rectFatoW") or 0.0)
            site.rect_tlof_l = float(fato.get("rectTlofL") or 0.0)
            site.rect_tlof_w = float(fato.get("rectTlofW") or 0.0)
            site.elevated = bool(fato.get("elevated"))
            site.night = (fato.get("cat") == 'vfr_night')
            if ols.get("grad"):
                site.slope = float(ols["grad"])
            if ols.get("transH") is not None:
                site.trans_h = float(ols["transH"])
            if ols.get("transG"):
                site.trans_slope = float(ols["transG"])
            if crs.get("siteElev") is not None:
                site.site_elev = float(crs["siteElev"])
            if crs.get("padH") is not None:
                site.pad_h = float(crs["padH"])
            if crs.get("convergence") is not None:
                site.convergence = float(crs["convergence"])
            cls = ols.get("centerlines") or []
            for i, cl in enumerate(cls[:2]):
                dp = site.dir1 if i == 0 else site.dir2
                dp.enabled = True
                dp.bearing = float(cl.get("bearing") or 0.0)
            if len(cls) < 2:
                site.dir2.enabled = False
        finally:
            _UPDATING = False

        regenerate_site(p, site)
        p.summary = (f"取込完了 [{site.name}]: {crs.get('epsg') or crs.get('type') or '座標系不明'} / "
                     f"γ={site.convergence:.4f}° / 進入方向 {len(cls)} | {p.geo_info}")
        self.report({'INFO'}, p.summary)
        return {'FINISHED'}


class VPOLS_OT_align_city(Operator):
    bl_idname = "vpols.align_city"
    bl_label = "都市モデルを原点整列"
    bl_options = {'REGISTER', 'UNDO'}

    origin_e: FloatProperty(name="原点 E [m]", default=0.0)
    origin_n: FloatProperty(name="原点 N [m]", default=0.0)
    z_offset: FloatProperty(name="Z オフセット [m]", default=0.0)

    def invoke(self, context, event):
        return context.window_manager.invoke_props_dialog(self)

    def execute(self, context):
        shift = Vector((-self.origin_e, -self.origin_n, self.z_offset))
        n = 0
        for o in context.selected_objects:
            if o.parent is None:
                o.location += shift
                n += 1
        self.report({'INFO'}, f"{n} オブジェクトを整列しました")
        return {'FINISHED'}



# =============================================================================
# SECTION 9.25 : PLATEAU API / CityGML / 距離ベースLOD
# =============================================================================

PLATEAU_GML_NS = "http://www.opengis.net/gml"
PLATEAU_XLINK_NS = "http://www.w3.org/1999/xlink"
PLATEAU_COLL_PREFIX = "VP_PLATEAU_"
PLATEAU_DEM_COLL_PREFIX = "VP_PLATEAU_DEM_"


def _xml_local(tag):
    return tag.rsplit('}', 1)[-1] if '}' in tag else tag


def plateau_collection_name(site):
    return f"{PLATEAU_COLL_PREFIX}{site.uid:03d}"


def get_plateau_collection(site):
    return get_collection(plateau_collection_name(site))


def plateau_dem_collection_name(site):
    return f"{PLATEAU_DEM_COLL_PREFIX}{site.uid:03d}"


def get_plateau_dem_collection(site):
    return get_collection(plateau_dem_collection_name(site))


def _plateau_cache_dir(p):
    """PLATEAUキャッシュの書込可能ディレクトリを返す。

    Blender の // 相対パスは .blend の保存先に依存するため、
    読取専用フォルダや権限制限フォルダでは WinError 5 になり得る。
    指定先へ実際にテスト書込みし、失敗した場合は
    LOCALAPPDATA -> TEMP の順で自動フォールバックする。
    """
    raw = (getattr(p, 'plateau_cache_dir', '') or '').strip()
    candidates = []

    if raw:
        try:
            resolved = bpy.path.abspath(raw)
        except Exception:
            resolved = os.path.abspath(os.path.expandvars(os.path.expanduser(raw)))
        if resolved:
            candidates.append(resolved)

    candidates.append(VPOLS_DEFAULT_CACHE_DIR)
    candidates.append(os.path.join(tempfile.gettempdir(), 'VP_OLS_PLATEAU_cache'))

    errors = []
    seen = set()
    for path in candidates:
        path = os.path.normpath(os.path.abspath(os.path.expandvars(os.path.expanduser(path))))
        key = os.path.normcase(path)
        if key in seen:
            continue
        seen.add(key)
        try:
            os.makedirs(path, exist_ok=True)
            test_path = os.path.join(path, f'.vpols_write_test_{os.getpid()}')
            with open(test_path, 'wb') as fp:
                fp.write(b'VP-OLS')
            try:
                os.remove(test_path)
            except OSError:
                pass

            # フォールバックした場合はUI上のパスも実際の保存先へ更新する。
            if raw != path:
                try:
                    p.plateau_cache_dir = path
                except Exception:
                    pass
            return path
        except Exception as e:
            errors.append(f'{path}: {e}')

    raise RuntimeError('PLATEAUキャッシュ保存先を作成できません。' + ' | '.join(errors))


def _geo_east_north(lat, lon, lat0, lon0):
    """JGD2011(GRS80)上の経緯度をサイト中心の局所 EN [m] へ。
    ECEF差分をENU回転するため、数km規模でも単純な度→m換算より安定する。
    高さは別途CityGML標高をそのまま利用する。"""
    a = 6378137.0
    inv_f = 298.257222101
    f = 1.0 / inv_f
    e2 = f * (2.0 - f)

    def ecef0(phi_deg, lam_deg):
        ph = math.radians(phi_deg)
        la = math.radians(lam_deg)
        sp, cp = math.sin(ph), math.cos(ph)
        sl, cl = math.sin(la), math.cos(la)
        N = a / math.sqrt(1.0 - e2 * sp * sp)
        return Vector((N * cp * cl, N * cp * sl, N * (1.0 - e2) * sp))

    q = ecef0(lat, lon)
    o = ecef0(lat0, lon0)
    d = q - o
    ph = math.radians(lat0)
    la = math.radians(lon0)
    sp, cp = math.sin(ph), math.cos(ph)
    sl, cl = math.sin(la), math.cos(la)
    east = -sl * d.x + cl * d.y
    north = -sp * cl * d.x - sp * sl * d.y + cp * d.z
    return east, north


def _plateau_bbox(lat, lon, radius_m):
    # GRS80局所曲率で半径を緯度経度差へ変換。API BBOX用なので僅かに広めに取る。
    a = 6378137.0
    inv_f = 298.257222101
    f = 1.0 / inv_f
    e2 = f * (2.0 - f)
    ph = math.radians(lat)
    sp = math.sin(ph)
    den = math.sqrt(1.0 - e2 * sp * sp)
    N = a / den
    M = a * (1.0 - e2) / (den ** 3)
    dlat = math.degrees(radius_m / M)
    dlon = math.degrees(radius_m / max(1.0, N * math.cos(ph)))
    return lon - dlon, lat - dlat, lon + dlon, lat + dlat


def _plateau_distance(lat, lon, lat0, lon0):
    e, n = _geo_east_north(lat, lon, lat0, lon0)
    return math.hypot(e, n)


def _plateau_lod_for_distance(p, dist):
    bands = sorted(list(p.plateau_lod_bands), key=lambda b: (b.min_dist, b.max_dist))
    for b in bands:
        lo = min(b.min_dist, b.max_dist)
        hi = max(b.min_dist, b.max_dist)
        if lo <= dist < hi or (abs(dist - hi) < 1e-6 and b is bands[-1]):
            return b.lod
    # 距離帯外は安全側で読み込まない
    return 'SKIP'


def _plateau_band_validate(p):
    bands = sorted([(min(b.min_dist, b.max_dist), max(b.min_dist, b.max_dist), b.lod)
                    for b in p.plateau_lod_bands], key=lambda x: x[0])
    if not bands:
        return False, "距離LOD帯がありません"
    last = -1.0
    for lo, hi, lod in bands:
        if hi <= lo + 1e-6:
            return False, f"距離帯 {lo:.0f}-{hi:.0f}m が無効です"
        if lo < last - 1e-6:
            return False, "距離LOD帯が重複しています"
        last = hi
    return True, ""


def _plateau_default_bands(p):
    p.plateau_lod_bands.clear()
    for lo, hi, lod in ((0.0, 300.0, 'LOD2'),
                        (300.0, 800.0, 'LOD1'),
                        (800.0, 1500.0, 'LOD1')):
        b = p.plateau_lod_bands.add()
        b.min_dist, b.max_dist, b.lod = lo, hi, lod
    p.plateau_lod_index = 0
    p.plateau_radius = 1500.0


def _plateau_api_json(url, timeout=45):
    """PLATEAU API JSON取得。HTTP/ネットワークエラーをBlender UIで読める形にする。"""
    req = urllib.request.Request(url, headers={
        'User-Agent': 'VP-OLS-Screening/0.8.6 Blender',
        'Accept': 'application/json',
    })
    try:
        with urllib.request.urlopen(req, timeout=max(5, int(timeout))) as res:
            raw = res.read()
            status = getattr(res, 'status', 200)
            ctype = (res.headers.get('Content-Type') or '').lower()
    except urllib.error.HTTPError as e:
        try:
            body = e.read(2048).decode('utf-8', 'replace').strip()
        except Exception:
            body = ''
        detail = f"HTTP {e.code} {e.reason}"
        if body:
            detail += f" / {body[:500]}"
        raise RuntimeError(detail)
    except urllib.error.URLError as e:
        raise RuntimeError(f"接続失敗: {getattr(e, 'reason', e)}")
    except Exception as e:
        raise RuntimeError(f"通信失敗: {e}")
    if status != 200:
        raise RuntimeError(f"HTTP {status}")
    if not raw:
        raise RuntimeError("API応答が空です")
    try:
        return json.loads(raw.decode('utf-8'))
    except Exception as e:
        preview = raw[:300].decode('utf-8', 'replace')
        raise RuntimeError(f"JSON解析失敗: {e} / Content-Type={ctype} / {preview}")


def _plateau_flatten_api_response(data, feature_type='bldg'):
    """API応答を指定地物型のCityGMLファイル一覧へ正規化する。

    feature_type: bldg / dem 等。現行APIは files[feature_type] にファイル一覧を返す。
    旧/簡略応答にも後方互換で対応する。
    """
    if isinstance(data, dict) and isinstance(data.get('cities'), list):
        datasets = data['cities']
    elif isinstance(data, list):
        datasets = data
    else:
        datasets = [data]
    out = []
    aliases = {'bldg': ('bldg', 'building'), 'dem': ('dem', 'relief')}
    keys = aliases.get(feature_type, (feature_type,))
    for ds in datasets:
        if not isinstance(ds, dict):
            continue
        city = ds.get('cityName') or ds.get('city') or ''
        year = int(ds.get('year') or 0)
        files = ds.get('files') or {}
        items = []
        for key in keys:
            v = files.get(key)
            if v:
                items = v
                break
        if isinstance(items, dict):
            items = list(items.values())
        for it in items or []:
            if not isinstance(it, dict) or not it.get('url'):
                continue
            out.append(dict(
                feature_type=feature_type,
                code=str(it.get('code') or ''), city=city, year=year,
                max_lod=int(it.get('maxLod') or 0),
                file_size=int(it.get('fileSize') or 0),
                features=int(it.get('features') or 0),
                lod0=int(it.get('lod0') or 0),
                lod1=int(it.get('lod1') or 0), lod2=int(it.get('lod2') or 0),
                url=str(it.get('url'))))
    dedup = {}
    for x in out:
        u = x['url']
        if u not in dedup or x['year'] >= dedup[u]['year']:
            dedup[u] = x
    return list(dedup.values())


def _download_to_cache(url, p):
    cache = _plateau_cache_dir(p)
    parsed = urllib.parse.urlparse(url)
    base = os.path.basename(parsed.path) or 'citygml.gml'
    stem, ext = os.path.splitext(base)
    if not ext:
        ext = '.gml'
    digest = hashlib.sha1(url.encode('utf-8')).hexdigest()[:10]
    path = os.path.join(cache, f"{stem}_{digest}{ext}")
    if p.plateau_use_cache and os.path.isfile(path) and os.path.getsize(path) > 0:
        return path, True
    part = path + '.part'
    try:
        if os.path.exists(part):
            os.remove(part)
    except Exception:
        pass
    req = urllib.request.Request(url, headers={
        'User-Agent': 'VP-OLS-Screening/0.8.6 Blender',
        'Accept': 'application/gml+xml, application/xml, text/xml, */*',
    })
    try:
        with urllib.request.urlopen(req, timeout=max(30, int(getattr(p, 'plateau_timeout', 45)) * 2)) as res, open(part, 'wb') as fp:
            ctype = (res.headers.get('Content-Type') or '').lower()
            first = b''
            while True:
                chunk = res.read(1024 * 1024)
                if not chunk:
                    break
                if not first:
                    first = chunk[:256]
                fp.write(chunk)
    except urllib.error.HTTPError as e:
        try:
            body = e.read(1024).decode('utf-8', 'replace').strip()
        except Exception:
            body = ''
        try:
            if os.path.exists(part): os.remove(part)
        except Exception:
            pass
        raise RuntimeError(f"CityGML DL HTTP {e.code} {e.reason}" + (f" / {body[:300]}" if body else ''))
    except urllib.error.URLError as e:
        try:
            if os.path.exists(part): os.remove(part)
        except Exception:
            pass
        raise RuntimeError(f"CityGML DL接続失敗: {getattr(e, 'reason', e)}")
    except Exception as e:
        try:
            if os.path.exists(part): os.remove(part)
        except Exception:
            pass
        raise RuntimeError(f"CityGML DL失敗: {e}")
    if not os.path.isfile(part) or os.path.getsize(part) == 0:
        raise RuntimeError("CityGMLダウンロード結果が空です")
    # HTMLエラーページやZIPをGMLとして誤解析しない
    head = first.lstrip().lower()
    if head.startswith(b'<html') or b'<!doctype html' in head[:128]:
        raise RuntimeError(f"GMLではなくHTMLが返されました (Content-Type={ctype})")
    if first.startswith(b'PK\x03\x04'):
        raise RuntimeError("個別GML URLからZIPが返されました。ローカル解凍後の .gml を指定してください")
    os.replace(part, path)
    return path, False



# -----------------------------------------------------------------------------
# PLATEAU 非同期ダウンロード / 進捗管理
# -----------------------------------------------------------------------------
_PLATEAU_ACTIVE_CANCEL = None


def _plateau_download_background(url, cache_dir, use_cache, timeout, shared, cancel_event):
    """bpyへ触れずに実行できるネットワークDLワーカー。

    shared は単純なdictのみを更新し、Blender RNAはメインスレッドのmodal()側で更新する。
    """
    parsed = urllib.parse.urlparse(url)
    base = os.path.basename(parsed.path) or 'citygml.gml'
    stem, ext = os.path.splitext(base)
    if not ext:
        ext = '.gml'
    digest = hashlib.sha1(url.encode('utf-8')).hexdigest()[:10]
    path = os.path.join(cache_dir, f"{stem}_{digest}{ext}")
    try:
        if use_cache and os.path.isfile(path) and os.path.getsize(path) > 0:
            sz = os.path.getsize(path)
            shared.update(done=sz, total=sz, cached=True, path=path, finished=True, error='')
            return
        part = path + '.part'
        try:
            if os.path.exists(part):
                os.remove(part)
        except Exception:
            pass
        req = urllib.request.Request(url, headers={
            'User-Agent': 'VP-OLS-Screening/0.8.6 Blender',
            'Accept': 'application/gml+xml, application/xml, text/xml, */*',
        })
        first = b''
        ctype = ''
        with urllib.request.urlopen(req, timeout=max(30, int(timeout) * 2)) as res, open(part, 'wb') as fp:
            ctype = (res.headers.get('Content-Type') or '').lower()
            try:
                header_total = int(res.headers.get('Content-Length') or 0)
            except Exception:
                header_total = 0
            total = header_total or int(shared.get('total') or 0)
            shared.update(total=total, done=0, cached=False)
            done = 0
            while True:
                if cancel_event.is_set():
                    raise RuntimeError('ユーザーがPLATEAU取得を中止しました')
                chunk = res.read(512 * 1024)
                if not chunk:
                    break
                if not first:
                    first = chunk[:256]
                fp.write(chunk)
                done += len(chunk)
                shared['done'] = done
        if not os.path.isfile(part) or os.path.getsize(part) == 0:
            raise RuntimeError('CityGMLダウンロード結果が空です')
        head = first.lstrip().lower()
        if head.startswith(b'<html') or b'<!doctype html' in head[:128]:
            raise RuntimeError(f'GMLではなくHTMLが返されました (Content-Type={ctype})')
        if first.startswith(b'PK\x03\x04'):
            raise RuntimeError('個別GML URLからZIPが返されました。ローカル解凍後の .gml を指定してください')
        os.replace(part, path)
        shared.update(path=path, finished=True, error='')
    except urllib.error.HTTPError as e:
        try:
            body = e.read(1024).decode('utf-8', 'replace').strip()
        except Exception:
            body = ''
        try:
            if 'part' in locals() and os.path.exists(part): os.remove(part)
        except Exception:
            pass
        shared.update(finished=True, error=f"CityGML DL HTTP {e.code} {e.reason}" + (f" / {body[:300]}" if body else ''))
    except urllib.error.URLError as e:
        try:
            if 'part' in locals() and os.path.exists(part): os.remove(part)
        except Exception:
            pass
        shared.update(finished=True, error=f"CityGML DL接続失敗: {getattr(e, 'reason', e)}")
    except Exception as e:
        try:
            if 'part' in locals() and os.path.exists(part): os.remove(part)
        except Exception:
            pass
        shared.update(finished=True, error=str(e))


def _plateau_progress_reset(p, total_files=0):
    p.plateau_progress_active = True
    p.plateau_progress_cancel_requested = False
    p.plateau_progress_overall = 0.0
    p.plateau_progress_file = 0.0
    p.plateau_progress_phase = '準備中'
    p.plateau_progress_file_name = ''
    p.plateau_progress_detail = ''
    p.plateau_progress_file_index = 0
    p.plateau_progress_file_total = int(total_files)
    p.plateau_progress_current_mb = 0.0
    p.plateau_progress_total_mb = 0.0
    p.plateau_progress_processed = 0
    p.plateau_progress_processed_label = ''


def _plateau_tag_redraw(context):
    try:
        if context.area:
            context.area.tag_redraw()
    except Exception:
        pass
    try:
        for win in context.window_manager.windows:
            for area in win.screen.areas:
                if area.type == 'VIEW_3D':
                    area.tag_redraw()
    except Exception:
        pass


_PLATEAU_LAST_FORCE_REDRAW = 0.0

def _plateau_force_redraw(context):
    """重いXML解析中も約4Hzで進捗UIを描画する。"""
    global _PLATEAU_LAST_FORCE_REDRAW
    now = time.monotonic()
    if now - _PLATEAU_LAST_FORCE_REDRAW < 0.25:
        return
    _PLATEAU_LAST_FORCE_REDRAW = now
    _plateau_tag_redraw(context)
    try:
        bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
    except Exception:
        pass


def _plateau_job_start(op, context, kind):
    global _PLATEAU_ACTIVE_CANCEL
    p = context.scene.vpols
    site = active_site(p)
    if site is None:
        op.report({'ERROR'}, 'サイトがありません')
        return {'CANCELLED'}
    if p.plateau_progress_active:
        op.report({'WARNING'}, '別のPLATEAU取得処理が実行中です')
        return {'CANCELLED'}
    source = p.plateau_files if kind == 'bldg' else p.plateau_dem_files
    selected = []
    for x in source:
        if x.selected:
            selected.append({
                'feature_type': x.feature_type, 'code': x.code, 'city': x.city,
                'year': int(x.year), 'max_lod': int(x.max_lod), 'file_size': int(x.file_size),
                'features': int(x.features), 'url': x.url,
            })
    if not selected:
        op.report({'ERROR'}, '取得するCityGMLを選択してください')
        return {'CANCELLED'}
    if kind == 'bldg':
        ok, msg = _plateau_band_validate(p)
        if not ok:
            op.report({'ERROR'}, msg)
            return {'CANCELLED'}
        coll = get_plateau_collection(site)
        if p.plateau_replace:
            clear_collection(coll)
    else:
        coll = get_plateau_dem_collection(site)
        if p.plateau_dem_replace:
            clear_collection(coll)

    op._job_kind = kind
    op._files = selected
    op._site_uid = int(site.uid)
    op._index = 0
    op._download_thread = None
    op._download_shared = None
    op._cancel_event = threading.Event()
    op._timer = None
    op._total_count = 0
    op._total_skip = 0
    op._lod_total = {1:0, 2:0, 3:0, 4:0}
    op._total_tri = 0
    op._objects = 0
    op._imported_files = 0
    op._blocked_size = 0
    op._failed_files = 0
    op._last_errors = []
    op._dem_original_limit = int(p.plateau_dem_max_triangles)
    op._dem_remaining = int(p.plateau_dem_max_triangles)
    _PLATEAU_ACTIVE_CANCEL = op._cancel_event
    _plateau_progress_reset(p, len(selected))
    p.plateau_progress_phase = '取得準備'
    total_mb = sum(max(0, f['file_size']) for f in selected) / 1024.0 / 1024.0
    p.plateau_progress_detail = f"選択 {len(selected)}ファイル / 約{total_mb:.1f}MB"
    wm = context.window_manager
    try:
        wm.progress_begin(0, 1000)
    except Exception:
        pass
    op._timer = wm.event_timer_add(0.15, window=context.window)
    wm.modal_handler_add(op)
    return {'RUNNING_MODAL'}


def _plateau_job_begin_file(op, context):
    p = context.scene.vpols
    if op._index >= len(op._files):
        return False
    f = op._files[op._index]
    p.plateau_progress_file_index = op._index + 1
    p.plateau_progress_file_total = len(op._files)
    p.plateau_progress_file_name = f.get('code') or os.path.basename(urllib.parse.urlparse(f.get('url','')).path)
    p.plateau_progress_file = 0.0
    p.plateau_progress_current_mb = 0.0
    p.plateau_progress_total_mb = max(0, f.get('file_size',0)) / 1024.0 / 1024.0
    p.plateau_progress_processed = 0
    p.plateau_progress_processed_label = ''

    if f.get('file_size') and f['file_size'] > p.plateau_max_download_mb * 1024 * 1024:
        op._blocked_size += 1
        msg = f"{f.get('feature_type','')} {f.get('code','')}: {f['file_size']/1024/1024:.1f}MB > 上限 {p.plateau_max_download_mb:.0f}MB"
        op._last_errors.append(msg)
        p.plateau_progress_phase = '容量上限でスキップ'
        p.plateau_progress_detail = msg
        op._index += 1
        return True

    cache_dir = _plateau_cache_dir(p)
    shared = {'done':0, 'total':max(0, f.get('file_size',0)), 'cached':False, 'finished':False, 'error':'', 'path':''}
    op._download_shared = shared
    p.plateau_progress_phase = 'CityGMLダウンロード'
    p.plateau_progress_detail = f"{op._index+1}/{len(op._files)} {f.get('city','')} {f.get('code','')}"
    t = threading.Thread(
        target=_plateau_download_background,
        args=(f.get('url',''), cache_dir, bool(p.plateau_use_cache), int(p.plateau_timeout), shared, op._cancel_event),
        daemon=True,
    )
    op._download_thread = t
    t.start()
    return True


def _plateau_job_update_download_ui(op, context):
    p = context.scene.vpols
    sh = op._download_shared or {}
    done = max(0, int(sh.get('done') or 0))
    total = max(0, int(sh.get('total') or 0))
    p.plateau_progress_current_mb = done / 1024.0 / 1024.0
    if total > 0:
        p.plateau_progress_total_mb = total / 1024.0 / 1024.0
        frac = max(0.0, min(1.0, done / total))
    else:
        frac = 0.0
    p.plateau_progress_file = frac
    n = max(1, len(op._files))
    p.plateau_progress_overall = max(0.0, min(1.0, (op._index + 0.55 * frac) / n))
    if sh.get('cached'):
        p.plateau_progress_detail = f"キャッシュ使用 / {p.plateau_progress_current_mb:.1f}MB"
    elif total > 0:
        p.plateau_progress_detail = f"DL {p.plateau_progress_current_mb:.1f} / {p.plateau_progress_total_mb:.1f} MB"
    else:
        p.plateau_progress_detail = f"DL {p.plateau_progress_current_mb:.1f} MB"
    try:
        context.window_manager.progress_update(int(p.plateau_progress_overall * 1000))
    except Exception:
        pass
    _plateau_tag_redraw(context)


def _plateau_job_parse_downloaded(op, context, path):
    p = context.scene.vpols
    site = active_site(p)
    # 読込中にサイト切替があっても開始時サイトを優先して探す。
    if site is None or int(site.uid) != op._site_uid:
        for ss in p.sites:
            if int(ss.uid) == op._site_uid:
                site = ss
                break
    if site is None:
        raise RuntimeError('取得開始時のサイトが見つかりません')
    n = max(1, len(op._files))
    f = op._files[op._index]
    p.plateau_progress_file = 0.58
    p.plateau_progress_overall = (op._index + 0.58) / n

    if op._job_kind == 'bldg':
        p.plateau_progress_phase = '建築物解析・Mesh生成'
        expected = max(0, int(f.get('features') or 0))
        def cb(parsed, imported):
            p.plateau_progress_processed = int(parsed)
            p.plateau_progress_processed_label = f"Building解析 {parsed:,} / 生成 {imported:,}"
            frac = min(1.0, parsed / expected) if expected > 0 else min(0.95, 0.05 + parsed / 20000.0)
            p.plateau_progress_file = 0.58 + 0.42 * frac
            p.plateau_progress_overall = min(0.999, (op._index + p.plateau_progress_file) / n)
            try: context.window_manager.progress_update(int(p.plateau_progress_overall*1000))
            except Exception: pass
            _plateau_force_redraw(context)
        res = import_plateau_citygml(path, p, site, report_cb=cb)
        op._imported_files += 1
        op._total_count += res['count']
        op._total_skip += res['skipped']
        for k,v in res['lod_counts'].items():
            op._lod_total[k] = op._lod_total.get(k,0) + v
    else:
        if op._dem_remaining <= 0:
            return
        p.plateau_progress_phase = 'DEM解析・TIN Mesh生成'
        old = int(p.plateau_dem_max_triangles)
        p.plateau_dem_max_triangles = int(op._dem_remaining)
        try:
            def cb(parsed, triangles):
                p.plateau_progress_processed = int(triangles)
                p.plateau_progress_processed_label = f"DEM面解析 {parsed:,} / 生成 {triangles:,} triangles"
                # DEMのAPI featuresはTIN三角形数ではないため、上限に対する生成率を補助指標とする。
                denom = max(1, int(op._dem_original_limit))
                frac = min(0.98, triangles / denom)
                p.plateau_progress_file = 0.58 + 0.42 * frac
                p.plateau_progress_overall = min(0.999, (op._index + p.plateau_progress_file) / n)
                try: context.window_manager.progress_update(int(p.plateau_progress_overall*1000))
                except Exception: pass
                _plateau_force_redraw(context)
            res = import_plateau_dem_citygml(path, p, site, report_cb=cb)
        finally:
            p.plateau_dem_max_triangles = old
        op._imported_files += 1
        op._total_tri += res['triangles']
        op._objects += res['objects']
        op._dem_remaining = max(0, op._dem_original_limit - op._total_tri)
        if res.get('hit_limit'):
            op._last_errors.append(f"DEM {f.get('code','')}: 三角形上限に到達")
    p.plateau_progress_file = 1.0
    p.plateau_progress_overall = min(0.999, (op._index + 1.0) / n)


def _plateau_job_finish(op, context, cancelled=False):
    global _PLATEAU_ACTIVE_CANCEL
    p = context.scene.vpols
    try:
        if op._timer is not None:
            context.window_manager.event_timer_remove(op._timer)
    except Exception:
        pass
    try:
        context.window_manager.progress_end()
    except Exception:
        pass
    _PLATEAU_ACTIVE_CANCEL = None
    if cancelled:
        p.plateau_progress_phase = 'キャンセル'
        p.plateau_progress_detail = 'PLATEAU取得を中止しました。完了済みのMesh/キャッシュは保持されています。'
        p.plateau_progress_active = False
        op.report({'WARNING'}, p.plateau_progress_detail)
        return {'CANCELLED'}

    p.plateau_progress_overall = 1.0
    p.plateau_progress_file = 1.0
    p.plateau_progress_phase = '完了'
    p.plateau_progress_active = False
    job_site = active_site(p)
    if job_site is None or int(job_site.uid) != op._site_uid:
        for ss in p.sites:
            if int(ss.uid) == op._site_uid:
                job_site = ss
                break
    if op._job_kind == 'bldg':
        coll = get_plateau_collection(job_site) if job_site else None
        if p.plateau_auto_target and op._total_count > 0 and coll:
            p.target_coll = coll
        if op._total_count == 0:
            detail = op._last_errors[-1] if op._last_errors else '建物0件'
            p.plateau_last_error = detail
            p.plateau_status = f"取込0件: 成功ファイル {op._imported_files} / 容量上限 {op._blocked_size} / 失敗 {op._failed_files}。{detail}"
            op.report({'ERROR'}, p.plateau_status)
            return {'CANCELLED'}
        p.plateau_status = (f"取込完了: {op._imported_files}ファイル / 建物 {op._total_count}件 "
                            f"(L1:{op._lod_total[1]} L2:{op._lod_total[2]} L3:{op._lod_total[3]} L4:{op._lod_total[4]}) / "
                            f"除外 {op._total_skip} / 容量上限 {op._blocked_size} / 失敗 {op._failed_files}")
        p.plateau_last_error = op._last_errors[-1] if op._last_errors else ''
        op.report({'INFO'}, p.plateau_status)
    else:
        coll = get_plateau_dem_collection(job_site) if job_site else None
        if p.plateau_auto_terrain and op._total_tri > 0 and coll:
            p.terrain_coll = coll
        if op._total_tri == 0:
            detail = op._last_errors[-1] if op._last_errors else 'DEM三角形0件'
            p.plateau_last_error = detail
            p.plateau_dem_status = f"DEM取込0件: 成功 {op._imported_files} / 容量上限 {op._blocked_size} / 失敗 {op._failed_files}。{detail}"
            op.report({'ERROR'}, p.plateau_dem_status)
            return {'CANCELLED'}
        limit_note = ' / 三角形上限到達' if op._dem_remaining <= 0 else ''
        p.plateau_dem_status = (f"DEM取込完了: {op._imported_files}ファイル / {op._total_tri:,}三角形 / {op._objects} MeshObject / "
                                f"容量上限 {op._blocked_size} / 失敗 {op._failed_files}{limit_note}")
        p.plateau_last_error = op._last_errors[-1] if op._last_errors else ''
        op.report({'INFO'}, p.plateau_dem_status)
    _plateau_tag_redraw(context)
    return {'FINISHED'}


def _plateau_job_modal(op, context, event):
    p = context.scene.vpols
    if event.type == 'ESC':
        op._cancel_event.set()
        p.plateau_progress_cancel_requested = True
        p.plateau_progress_phase = 'キャンセル要求'
        p.plateau_progress_detail = '現在のダウンロード停止を待っています…'
    if event.type != 'TIMER':
        return {'PASS_THROUGH'}

    if op._cancel_event.is_set() and (op._download_thread is None or not op._download_thread.is_alive()):
        return _plateau_job_finish(op, context, cancelled=True)

    if op._download_thread is None:
        if op._index >= len(op._files):
            return _plateau_job_finish(op, context, cancelled=False)
        # DEM上限到達時は未処理ファイルを残したまま終了し、状態を明示する。
        if op._job_kind == 'dem' and op._dem_remaining <= 0:
            p.plateau_progress_detail = f"DEM三角形上限 {op._dem_original_limit:,} に到達。残り {len(op._files)-op._index}ファイルは未処理。"
            return _plateau_job_finish(op, context, cancelled=False)
        _plateau_job_begin_file(op, context)
        if op._download_thread is None:  # 容量上限スキップ
            n=max(1,len(op._files)); p.plateau_progress_overall=min(0.999,op._index/n)
        return {'RUNNING_MODAL'}

    if op._download_thread.is_alive():
        _plateau_job_update_download_ui(op, context)
        return {'RUNNING_MODAL'}

    # ダウンロードスレッド終了
    _plateau_job_update_download_ui(op, context)
    sh = op._download_shared or {}
    op._download_thread = None
    if op._cancel_event.is_set():
        return _plateau_job_finish(op, context, cancelled=True)
    if sh.get('error'):
        op._failed_files += 1
        msg = f"{op._job_kind} {op._files[op._index].get('code','')}: 取得失敗 {sh.get('error')}"
        op._last_errors.append(msg)
        p.plateau_last_error = msg
        p.plateau_progress_phase = '取得失敗'
        p.plateau_progress_detail = msg
        op._index += 1
        return {'RUNNING_MODAL'}
    try:
        _plateau_job_parse_downloaded(op, context, sh.get('path',''))
    except Exception as e:
        op._failed_files += 1
        msg = f"{op._job_kind} {op._files[op._index].get('code','')}: 取込失敗 {e}"
        op._last_errors.append(msg)
        p.plateau_last_error = msg
        p.plateau_progress_phase = '解析/生成失敗'
        p.plateau_progress_detail = msg
        op.report({'WARNING'}, msg)
    op._index += 1
    op._download_shared = None
    return {'RUNNING_MODAL'}


class VPOLS_OT_plateau_cancel_job(Operator):
    bl_idname = 'vpols.plateau_cancel_job'
    bl_label = 'PLATEAU取得をキャンセル'
    bl_description = '実行中のPLATEAUダウンロードを停止する。解析中は現在ファイルの処理完了後に停止する場合があります'

    def execute(self, context):
        global _PLATEAU_ACTIVE_CANCEL
        p = context.scene.vpols
        if _PLATEAU_ACTIVE_CANCEL is None or not p.plateau_progress_active:
            self.report({'INFO'}, '実行中のPLATEAU取得はありません')
            return {'CANCELLED'}
        p.plateau_progress_cancel_requested = True
        p.plateau_progress_phase = 'キャンセル要求'
        p.plateau_progress_detail = '現在処理の安全な停止点で中止します'
        _PLATEAU_ACTIVE_CANCEL.set()
        self.report({'WARNING'}, 'PLATEAU取得のキャンセルを要求しました')
        return {'FINISHED'}

def _poslist_points(el):
    txt = (el.text or '').strip()
    if not txt:
        return []
    try:
        nums = [float(v) for v in txt.split()]
    except Exception:
        return []
    dim = int(el.attrib.get('srsDimension') or 0)
    if dim not in (2, 3):
        dim = 3 if len(nums) % 3 == 0 else 2
    pts = []
    for i in range(0, len(nums) - dim + 1, dim):
        a, b = nums[i], nums[i + 1]
        z = nums[i + 2] if dim == 3 else 0.0
        # PLATEAU EPSG:6697 は緯度,経度,標高。念のため逆順も判別。
        if abs(a) <= 90.0 and abs(b) <= 180.0:
            lat, lon = a, b
        elif abs(b) <= 90.0 and abs(a) <= 180.0:
            lat, lon = b, a
        else:
            continue
        pts.append((lat, lon, z))
    return pts


def _ring_points(ring):
    for e in ring.iter():
        if _xml_local(e.tag) == 'posList':
            pts = _poslist_points(e)
            if pts:
                return pts
    pts = []
    for e in ring.iter():
        if _xml_local(e.tag) == 'pos':
            q = _poslist_points(e)
            if q:
                pts.extend(q)
    return pts


def _polygon_exterior(poly):
    ext = None
    for e in poly.iter():
        if _xml_local(e.tag) == 'exterior':
            ext = e
            break
    scope = ext if ext is not None else poly
    for e in scope.iter():
        if _xml_local(e.tag) == 'LinearRing':
            pts = _ring_points(e)
            if len(pts) >= 3:
                if len(pts) >= 2 and all(abs(pts[0][i] - pts[-1][i]) < 1e-10 for i in range(3)):
                    pts.pop()
                return pts
    return []


def _iter_polygons_resolved(el, idmap, seen=None):
    if seen is None:
        seen = set()
    key = id(el)
    if key in seen:
        return
    seen.add(key)
    for e in el.iter():
        if _xml_local(e.tag) == 'Polygon':
            yield e
        href = e.attrib.get(f'{{{PLATEAU_XLINK_NS}}}href') or e.attrib.get('href')
        if href and href.startswith('#'):
            target = idmap.get(href[1:])
            if target is not None and id(target) not in seen:
                yield from _iter_polygons_resolved(target, idmap, seen)


def _available_lods(building):
    lods = set()
    for e in building.iter():
        n = _xml_local(e.tag)
        if len(n) >= 4 and n[:3].lower() == 'lod' and n[3].isdigit():
            lods.add(int(n[3]))
    return sorted(lods)


def _select_lod(building, requested, fallback_lower=True):
    available = _available_lods(building)
    if requested in available:
        return requested
    if fallback_lower:
        lower = [x for x in available if x < requested]
        if lower:
            return max(lower)
    return None


def _building_sample_center(building):
    # 最大128点だけ採り、緯度経度平均を建物中心近似に使う。
    vals = []
    for e in building.iter():
        if _xml_local(e.tag) not in {'posList', 'pos'}:
            continue
        for q in _poslist_points(e):
            vals.append(q)
            if len(vals) >= 128:
                break
        if len(vals) >= 128:
            break
    if not vals:
        return None
    return (sum(q[0] for q in vals) / len(vals), sum(q[1] for q in vals) / len(vals))


def _extract_building_polygons(building, lod, idmap):
    roots = []
    prefix = f'lod{lod}'
    for e in building.iter():
        if _xml_local(e.tag).lower().startswith(prefix):
            roots.append(e)
    polys = []
    seen_poly = set()
    for r in roots:
        for poly in _iter_polygons_resolved(r, idmap):
            gid = poly.attrib.get(f'{{{PLATEAU_GML_NS}}}id')
            k = gid or id(poly)
            if k in seen_poly:
                continue
            seen_poly.add(k)
            pts = _polygon_exterior(poly)
            if len(pts) >= 3:
                polys.append(pts)
    return polys


def _make_plateau_building(site, coll, root_obj, gml_id, lod, dist, polys, source_name):
    verts, faces = [], []
    for ring in polys:
        face = []
        for lat, lon, z in ring:
            e, n = _geo_east_north(lat, lon, site.geo_lat, site.geo_lon)
            face.append(len(verts))
            verts.append((e, n, z))
        if len(face) >= 3:
            faces.append(face)
    if not faces:
        return None
    safe_id = (gml_id or 'building').replace(':', '_').replace('/', '_')
    name = f"PLATEAU_{safe_id}"
    if name in bpy.data.objects:
        # gml:id重複時はソース名ハッシュを付与
        name += '_' + hashlib.sha1((source_name + safe_id).encode('utf-8')).hexdigest()[:6]
    me = bpy.data.meshes.new(name)
    me.from_pydata(verts, [], faces)
    me.validate(verbose=False)
    me.update()
    # 法線を可能な範囲で統一
    try:
        bm = bmesh.new()
        bm.from_mesh(me)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
        bm.to_mesh(me)
        bm.free()
    except Exception:
        pass
    obj = bpy.data.objects.new(name, me)
    coll.objects.link(obj)
    obj.parent = root_obj
    obj.matrix_parent_inverse.identity()
    obj.matrix_basis.identity()
    obj['plateau_gml_id'] = gml_id or ''
    obj['plateau_lod'] = int(lod)
    obj['plateau_distance_m'] = float(dist)
    obj['plateau_source'] = source_name
    obj['plateau_citygml'] = True
    return obj


def import_plateau_citygml(filepath, p, site, report_cb=None):
    """PLATEAU建築物CityGMLを逐次解析してBlender Mesh化する。

    v0.8.1ではET.parse()によるファイル全体のDOM化をやめ、Buildingのend eventごとに
    処理・clearする。大容量GMLでもメモリ使用量を抑え、0件時は除外理由を返す。
    """
    ok, msg = _plateau_band_validate(p)
    if not ok:
        raise RuntimeError(msg)
    if not os.path.isfile(filepath):
        raise RuntimeError(f"CityGMLが見つかりません: {filepath}")

    coll = get_plateau_collection(site)
    site_coll = get_site_collection(site)
    root_obj = get_site_root(site, site_coll)
    source_name = os.path.basename(filepath)
    count = 0
    building_total = 0
    lod_counts = {1: 0, 2: 0, 3: 0, 4: 0}
    reasons = {
        'center_failed': 0, 'outside_radius': 0, 'band_skip': 0,
        'no_lod': 0, 'no_geometry': 0, 'mesh_failed': 0,
    }
    min_dist = None
    srs = ''

    try:
        events = ET.iterparse(filepath, events=('end',))
        for event, elem in events:
            local = _xml_local(elem.tag)
            if local == 'Envelope' and not srs:
                srs = elem.attrib.get('srsName', '')
                if srs and not any(x in srs for x in ('6697', '6668')):
                    raise RuntimeError(f"未対応CRS: {srs}（EPSG:6697/6668 CityGMLを想定）")
                elem.clear()
                continue
            if local != 'Building':
                continue

            building_total += 1
            if count >= p.plateau_max_buildings:
                elem.clear()
                continue

            center = _building_sample_center(elem)
            if center is None:
                reasons['center_failed'] += 1
                elem.clear()
                continue
            dist = _plateau_distance(center[0], center[1], site.geo_lat, site.geo_lon)
            min_dist = dist if min_dist is None else min(min_dist, dist)
            if dist > p.plateau_radius + 1e-6:
                reasons['outside_radius'] += 1
                elem.clear()
                continue

            lod_key = _plateau_lod_for_distance(p, dist)
            if lod_key == 'SKIP':
                reasons['band_skip'] += 1
                elem.clear()
                continue
            req_lod = int(lod_key[-1])
            use_lod = _select_lod(elem, req_lod, p.plateau_fallback_lower)
            if use_lod is None:
                reasons['no_lod'] += 1
                elem.clear()
                continue

            # xlink解決はBuilding内部で完結するPLATEAU標準構造を主対象とする。
            idmap = {}
            for e in elem.iter():
                gid0 = e.attrib.get(f'{{{PLATEAU_GML_NS}}}id')
                if gid0:
                    idmap[gid0] = e
            polys = _extract_building_polygons(elem, use_lod, idmap)
            if not polys:
                part_polys = []
                for part in elem.iter():
                    if _xml_local(part.tag) == 'BuildingPart':
                        plod = _select_lod(part, req_lod, p.plateau_fallback_lower)
                        if plod is not None:
                            part_polys.extend(_extract_building_polygons(part, plod, idmap))
                polys = part_polys
            if not polys:
                reasons['no_geometry'] += 1
                elem.clear()
                continue

            gid = elem.attrib.get(f'{{{PLATEAU_GML_NS}}}id') or f"B{building_total:06d}"
            obj = _make_plateau_building(site, coll, root_obj, gid, use_lod, dist, polys, source_name)
            if obj:
                count += 1
                lod_counts[use_lod] = lod_counts.get(use_lod, 0) + 1
            else:
                reasons['mesh_failed'] += 1

            if report_cb and building_total % 250 == 0:
                try:
                    report_cb(building_total, count)
                except Exception:
                    pass
            elem.clear()
    except ET.ParseError as e:
        raise RuntimeError(f"CityGML XML解析失敗: {e}")
    except RuntimeError:
        raise
    except Exception as e:
        raise RuntimeError(f"CityGML解析中のエラー: {type(e).__name__}: {e}")

    skipped = sum(reasons.values())
    diag = (f"Building={building_total}, import={count}, "
            f"距離外={reasons['outside_radius']}, band除外={reasons['band_skip']}, "
            f"LODなし={reasons['no_lod']}, geometryなし={reasons['no_geometry']}, "
            f"中心取得失敗={reasons['center_failed']}")
    if min_dist is not None:
        diag += f", 最近傍={min_dist:.1f}m"

    if count == 0:
        if building_total == 0:
            raise RuntimeError(f"建築物Building要素が0件です。bldg CityGMLか確認してください / {source_name}")
        if reasons['outside_radius'] >= max(1, building_total - reasons['center_failed']):
            raise RuntimeError(
                f"建物は存在しますが全て取得半径外です。サイト緯度経度を確認してください。{diag}")
        if reasons['no_lod'] > 0 and reasons['no_geometry'] == 0:
            raise RuntimeError(
                f"指定LODを持つ建物がありません。下位LODフォールバックをONにするか距離LODを変更してください。{diag}")
        raise RuntimeError(f"CityGMLからMeshを生成できませんでした。{diag}")

    return dict(count=count, skipped=skipped, lod_counts=lod_counts, srs=srs,
                building_total=building_total, reasons=reasons, min_dist=min_dist, diag=diag)


class VPOLS_OT_plateau_band_add(Operator):
    bl_idname = "vpols.plateau_band_add"
    bl_label = "距離LOD帯を追加"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        b = p.plateau_lod_bands.add()
        if len(p.plateau_lod_bands) > 1:
            prev = p.plateau_lod_bands[-2]
            start = max(prev.min_dist, prev.max_dist)
        else:
            start = 0.0
        b.min_dist = start
        b.max_dist = start + 500.0
        b.lod = 'LOD1'
        p.plateau_lod_index = len(p.plateau_lod_bands) - 1
        return {'FINISHED'}


class VPOLS_OT_plateau_band_remove(Operator):
    bl_idname = "vpols.plateau_band_remove"
    bl_label = "距離LOD帯を削除"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        i = p.plateau_lod_index
        if 0 <= i < len(p.plateau_lod_bands):
            p.plateau_lod_bands.remove(i)
            p.plateau_lod_index = min(i, len(p.plateau_lod_bands) - 1)
        return {'FINISHED'}


class VPOLS_OT_plateau_band_default(Operator):
    bl_idname = "vpols.plateau_band_default"
    bl_label = "距離LODを標準設定"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        _plateau_default_bands(context.scene.vpols)
        self.report({'INFO'}, "距離LOD: 0-300m LOD2 / 300-800m LOD1 / 800-1500m LOD1")
        return {'FINISHED'}


class VPOLS_OT_plateau_api_test(Operator):
    bl_idname = "vpols.plateau_api_test"
    bl_label = "PLATEAU API接続テスト"
    bl_description = "現在のサイト中心点でPLATEAU APIへ接続し、建築物CityGML一覧が取得できるか確認"

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        base = p.plateau_api_base.rstrip('/')
        cond = f"r:{site.geo_lon:.10f},{site.geo_lat:.10f}"
        url = f"{base}/datacatalog/citygml/{cond}?types=bldg,dem"
        p.plateau_last_url = url
        p.plateau_last_error = ""
        try:
            data = _plateau_api_json(url, p.plateau_timeout)
            rows = _plateau_flatten_api_response(data, 'bldg')
            drows = _plateau_flatten_api_response(data, 'dem')
        except Exception as e:
            p.plateau_last_error = str(e)
            p.plateau_status = f"API接続テスト失敗: {e}"
            self.report({'ERROR'}, p.plateau_status)
            return {'CANCELLED'}
        cities = len(data.get('cities', [])) if isinstance(data, dict) else 0
        p.plateau_status = f"API接続OK: 都市 {cities} / bldg {len(rows)} / dem {len(drows)} / {site.geo_lat:.6f},{site.geo_lon:.6f}"
        self.report({'INFO'}, p.plateau_status)
        return {'FINISHED'}


class VPOLS_OT_plateau_search(Operator):
    bl_idname = "vpols.plateau_search"
    bl_label = "PLATEAU検索"
    bl_description = "アクティブVPの緯度経度と半径から建築物CityGMLを検索"

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        ok, msg = _plateau_band_validate(p)
        if not ok:
            self.report({'ERROR'}, msg)
            return {'CANCELLED'}
        radius = p.plateau_radius + p.plateau_query_margin
        x1, y1, x2, y2 = _plateau_bbox(site.geo_lat, site.geo_lon, radius)
        cond = f"r:{x1:.10f},{y1:.10f},{x2:.10f},{y2:.10f}"
        base = p.plateau_api_base.rstrip('/')
        url = f"{base}/datacatalog/citygml/{cond}?types=bldg"
        p.plateau_last_url = url
        p.plateau_last_error = ""
        try:
            data = _plateau_api_json(url, p.plateau_timeout)
            rows = _plateau_flatten_api_response(data, 'bldg')
        except Exception as e:
            p.plateau_last_error = str(e)
            self.report({'ERROR'}, f"PLATEAU API失敗: {e}")
            p.plateau_status = f"API失敗: {e}"
            return {'CANCELLED'}
        p.plateau_files.clear()
        if not rows:
            p.plateau_status = (f"検索結果0件: {site.geo_lat:.6f},{site.geo_lon:.6f} / "
                                f"半径 {p.plateau_radius:.0f}m。緯度経度またはPLATEAU整備範囲を確認")
            self.report({'WARNING'}, p.plateau_status)
            return {'FINISHED'}
        total = 0
        for x in rows:
            it = p.plateau_files.add()
            for k in ('feature_type', 'code', 'city', 'year', 'max_lod', 'file_size', 'features', 'lod1', 'lod2', 'url'):
                setattr(it, k, x.get(k, 'bldg' if k == 'feature_type' else 0 if k in {'year','max_lod','file_size','features','lod1','lod2'} else ''))
            it.selected = True
            total += it.file_size
        p.plateau_status = (f"検索: {len(rows)}ファイル / 約{total/1024/1024:.1f}MB / "
                            f"中心 {site.geo_lat:.6f},{site.geo_lon:.6f} / 半径 {p.plateau_radius:.0f}m")
        self.report({'INFO'}, p.plateau_status)
        return {'FINISHED'}


class VPOLS_OT_plateau_import_selected(Operator):
    bl_idname = "vpols.plateau_import_selected"
    bl_label = "選択建築物をDL・距離LOD取込"
    bl_description = "選択したbldg CityGMLを順次取得し、進捗を表示しながら距離LODでBlender Mesh化"
    bl_options = {'REGISTER', 'UNDO'}

    def invoke(self, context, event):
        return _plateau_job_start(self, context, 'bldg')

    def execute(self, context):
        return _plateau_job_start(self, context, 'bldg')

    def modal(self, context, event):
        return _plateau_job_modal(self, context, event)

    def cancel(self, context):
        try:
            self._cancel_event.set()
        except Exception:
            pass



class VPOLS_OT_plateau_import_gml(Operator, ImportHelper):
    bl_idname = "vpols.plateau_import_gml"
    bl_label = "ローカルCityGMLを距離LOD取込"
    filename_ext = ".gml"
    filter_glob: StringProperty(default="*.gml;*.xml", options={'HIDDEN'})
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        coll = get_plateau_collection(site)
        if p.plateau_replace:
            clear_collection(coll)
        try:
            res = import_plateau_citygml(self.filepath, p, site)
        except Exception as e:
            p.plateau_last_error = str(e)
            p.plateau_status = f"CityGML取込失敗: {e}"
            self.report({'ERROR'}, str(e))
            return {'CANCELLED'}
        if p.plateau_auto_target:
            p.target_coll = coll
        lc = res['lod_counts']
        p.plateau_status = (f"CityGML取込: {res['count']}件 / "
                            f"L1:{lc[1]} L2:{lc[2]} L3:{lc[3]} L4:{lc[4]} / 除外:{res['skipped']}")
        self.report({'INFO'}, p.plateau_status)
        return {'FINISHED'}



# -----------------------------------------------------------------------------
# PLATEAU 地形モデル (dem) CityGML
# -----------------------------------------------------------------------------
def _make_plateau_dem_chunk(site, coll, root_obj, verts, faces, source_name, chunk_no):
    if not verts or not faces:
        return None
    name = f"PLATEAU_DEM_{site.uid:03d}_{chunk_no:03d}"
    if name in bpy.data.objects:
        name += '_' + hashlib.sha1((source_name + str(chunk_no)).encode('utf-8')).hexdigest()[:6]
    me = bpy.data.meshes.new(name)
    me.from_pydata(verts, [], faces)
    me.validate(verbose=False)
    me.update()
    try:
        bm = bmesh.new()
        bm.from_mesh(me)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
        bm.to_mesh(me)
        bm.free()
    except Exception:
        pass
    obj = bpy.data.objects.new(name, me)
    coll.objects.link(obj)
    obj.parent = root_obj
    obj.matrix_parent_inverse.identity()
    obj.matrix_basis.identity()
    obj['plateau_dem'] = True
    obj['plateau_source'] = source_name
    obj['plateau_citygml'] = True
    return obj


def import_plateau_dem_citygml(filepath, p, site, report_cb=None):
    """PLATEAU dem CityGMLのTIN Triangle/Polygonを逐次解析して地形Mesh化する。

    API上のdemは地形モデル。CityGMLのgml:Triangleを主対象とし、Polygonも救済する。
    指定取得半径外の面は生成せず、専用DEM Collectionへ格納する。
    """
    if not os.path.isfile(filepath):
        raise RuntimeError(f"DEM CityGMLが見つかりません: {filepath}")
    coll = get_plateau_dem_collection(site)
    site_coll = get_site_collection(site)
    root_obj = get_site_root(site, site_coll)
    source_name = os.path.basename(filepath)
    radius = max(1.0, float(p.plateau_radius))
    tri_limit = max(1000, int(p.plateau_dem_max_triangles))
    chunk_limit = 50000
    verts, faces = [], []
    vmap = {}
    chunks = 0
    triangles = 0
    parsed_faces = 0
    outside = 0
    bad_geom = 0
    srs = ''
    hit_limit = False

    def flush():
        nonlocal verts, faces, vmap, chunks
        if not faces:
            return
        chunks += 1
        _make_plateau_dem_chunk(site, coll, root_obj, verts, faces, source_name, chunks)
        verts, faces, vmap = [], [], {}

    def add_ring(ring):
        nonlocal triangles, outside, bad_geom, hit_limit
        if len(ring) < 3:
            bad_geom += 1
            return
        local_pts = []
        in_radius = False
        for lat, lon, z in ring:
            e, n = _geo_east_north(lat, lon, site.geo_lat, site.geo_lon)
            local_pts.append((e, n, z))
            if e*e + n*n <= radius*radius:
                in_radius = True
        if not in_radius:
            ce = sum(q[0] for q in local_pts) / len(local_pts)
            cn = sum(q[1] for q in local_pts) / len(local_pts)
            in_radius = ce*ce + cn*cn <= radius*radius
        if not in_radius:
            outside += 1
            return
        # Triangleは3点、Polygonの場合はfan triangulation。
        base = local_pts[0]
        for j in range(1, len(local_pts)-1):
            if triangles >= tri_limit:
                hit_limit = True
                return
            tri = (base, local_pts[j], local_pts[j+1])
            face = []
            for q in tri:
                key = (round(q[0], 3), round(q[1], 3), round(q[2], 3))
                idx = vmap.get(key)
                if idx is None:
                    idx = len(verts)
                    vmap[key] = idx
                    verts.append(q)
                face.append(idx)
            if len(set(face)) == 3:
                faces.append(face)
                triangles += 1
            if len(faces) >= chunk_limit:
                flush()

    try:
        events = ET.iterparse(filepath, events=('end',))
        for _event, elem in events:
            local = _xml_local(elem.tag)
            if local == 'Envelope' and not srs:
                srs = elem.attrib.get('srsName', '')
                if srs and not any(x in srs for x in ('6697', '6668')):
                    raise RuntimeError(f"未対応DEM CRS: {srs}（EPSG:6697/6668を想定）")
                elem.clear()
                continue
            if local not in {'Triangle', 'Polygon'}:
                continue
            parsed_faces += 1
            ring = _polygon_exterior(elem)
            if ring:
                add_ring(ring)
            else:
                bad_geom += 1
            elem.clear()
            if report_cb and parsed_faces % 5000 == 0:
                try:
                    report_cb(parsed_faces, triangles)
                except Exception:
                    pass
            if hit_limit:
                break
        flush()
    except ET.ParseError as e:
        raise RuntimeError(f"DEM CityGML XML解析失敗: {e}")
    except RuntimeError:
        raise
    except Exception as e:
        raise RuntimeError(f"DEM CityGML解析中のエラー: {type(e).__name__}: {e}")

    if parsed_faces == 0:
        raise RuntimeError(f"DEM地形面(Triangle/Polygon)が0件です。dem CityGMLか確認してください / {source_name}")
    if triangles == 0:
        raise RuntimeError(f"DEM地形面は存在しますが取得半径内のMeshを生成できません。面={parsed_faces}, 範囲外={outside}, geometry不良={bad_geom}")
    return dict(triangles=triangles, parsed_faces=parsed_faces, outside=outside,
                bad_geom=bad_geom, objects=chunks, srs=srs, hit_limit=hit_limit)


class VPOLS_OT_plateau_dem_search(Operator):
    bl_idname = "vpols.plateau_dem_search"
    bl_label = "PLATEAU DEM検索"
    bl_description = "アクティブVPの緯度経度と半径からPLATEAU地形モデル(dem) CityGMLを検索"

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        radius = p.plateau_radius + p.plateau_query_margin
        x1, y1, x2, y2 = _plateau_bbox(site.geo_lat, site.geo_lon, radius)
        cond = f"r:{x1:.10f},{y1:.10f},{x2:.10f},{y2:.10f}"
        base = p.plateau_api_base.rstrip('/')
        url = f"{base}/datacatalog/citygml/{cond}?types=dem"
        p.plateau_last_url = url
        p.plateau_last_error = ""
        try:
            data = _plateau_api_json(url, p.plateau_timeout)
            rows = _plateau_flatten_api_response(data, 'dem')
        except Exception as e:
            p.plateau_last_error = str(e)
            p.plateau_dem_status = f"DEM API失敗: {e}"
            self.report({'ERROR'}, p.plateau_dem_status)
            return {'CANCELLED'}
        p.plateau_dem_files.clear()
        total = 0
        for x in rows:
            it = p.plateau_dem_files.add()
            for k in ('feature_type','code','city','year','max_lod','file_size','features','lod1','lod2','url'):
                setattr(it, k, x.get(k, 0 if k in {'year','max_lod','file_size','features','lod1','lod2'} else ''))
            it.selected = True
            total += it.file_size
        if not rows:
            p.plateau_dem_status = f"DEM検索結果0件: {site.geo_lat:.6f},{site.geo_lon:.6f} / 半径 {p.plateau_radius:.0f}m"
            self.report({'WARNING'}, p.plateau_dem_status)
            return {'FINISHED'}
        p.plateau_dem_status = f"DEM検索: {len(rows)}ファイル / 約{total/1024/1024:.1f}MB / 半径 {p.plateau_radius:.0f}m"
        self.report({'INFO'}, p.plateau_dem_status)
        return {'FINISHED'}


class VPOLS_OT_plateau_dem_import_selected(Operator):
    bl_idname = "vpols.plateau_dem_import_selected"
    bl_label = "選択DEMをDL・取込"
    bl_description = "選択したdem CityGMLを順次取得し、進捗を表示しながらTIN地形Meshを生成"
    bl_options = {'REGISTER', 'UNDO'}

    def invoke(self, context, event):
        return _plateau_job_start(self, context, 'dem')

    def execute(self, context):
        return _plateau_job_start(self, context, 'dem')

    def modal(self, context, event):
        return _plateau_job_modal(self, context, event)

    def cancel(self, context):
        try:
            self._cancel_event.set()
        except Exception:
            pass



class VPOLS_OT_plateau_import_dem_gml(Operator, ImportHelper):
    bl_idname = "vpols.plateau_import_dem_gml"
    bl_label = "ローカルDEM CityGMLを取込"
    filename_ext = ".gml"
    filter_glob: StringProperty(default="*.gml;*.xml", options={'HIDDEN'})
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        coll = get_plateau_dem_collection(site)
        if p.plateau_dem_replace:
            clear_collection(coll)
        try:
            res = import_plateau_dem_citygml(self.filepath, p, site)
        except Exception as e:
            p.plateau_last_error = str(e)
            p.plateau_dem_status = f"DEM CityGML取込失敗: {e}"
            self.report({'ERROR'}, p.plateau_dem_status)
            return {'CANCELLED'}
        if p.plateau_auto_terrain:
            p.terrain_coll = coll
        p.plateau_dem_status = f"DEM CityGML取込: {res['triangles']:,}三角形 / {res['objects']} MeshObject"
        self.report({'INFO'}, p.plateau_dem_status)
        return {'FINISHED'}


class VPOLS_OT_plateau_dem_clear(Operator):
    bl_idname = "vpols.plateau_dem_clear"
    bl_label = "PLATEAU DEMを削除"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            return {'CANCELLED'}
        coll = bpy.data.collections.get(plateau_dem_collection_name(site))
        if coll:
            clear_collection(coll)
        if p.terrain_coll == coll:
            p.terrain_coll = None
        p.plateau_dem_status = "PLATEAU DEMを削除しました"
        return {'FINISHED'}


class VPOLS_OT_plateau_import_mesh(Operator, ImportHelper):
    bl_idname = "vpols.plateau_import_mesh"
    bl_label = "OBJ / FBX / glTFを直接取込"
    bl_description = "PLATEAU等から変換済みのOBJ/FBX/glTFを都市モデルCollectionへ取り込む（距離LOD選択はCityGMLのみ）"
    filename_ext = ".obj"
    filter_glob: StringProperty(default="*.obj;*.fbx;*.glb;*.gltf", options={'HIDDEN'})
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        ext = os.path.splitext(self.filepath)[1].lower()
        before = set(bpy.data.objects)
        try:
            if ext == '.obj':
                # Blender 4.x
                if hasattr(bpy.ops.wm, 'obj_import'):
                    bpy.ops.wm.obj_import(filepath=self.filepath)
                else:
                    bpy.ops.import_scene.obj(filepath=self.filepath)
            elif ext == '.fbx':
                bpy.ops.import_scene.fbx(filepath=self.filepath)
            elif ext in {'.glb', '.gltf'}:
                bpy.ops.import_scene.gltf(filepath=self.filepath)
            else:
                raise RuntimeError(f"未対応形式: {ext}")
        except Exception as e:
            self.report({'ERROR'}, f"3Dモデル取込失敗: {e}")
            return {'CANCELLED'}

        new_objs = [o for o in bpy.data.objects if o not in before]
        coll = get_plateau_collection(site)
        site_coll = get_site_collection(site)
        root_obj = get_site_root(site, site_coll)
        nmesh = 0
        for obj in new_objs:
            # 既存ワールド変換を保持したまま専用Collectionへ集約
            mw = obj.matrix_world.copy()
            for c in list(obj.users_collection):
                try:
                    c.objects.unlink(obj)
                except Exception:
                    pass
            if obj.name not in coll.objects:
                coll.objects.link(obj)
            if obj.parent is None:
                obj.parent = root_obj
                obj.matrix_world = mw
            if obj.type == 'MESH':
                obj['plateau_external_mesh'] = True
                obj['plateau_source'] = os.path.basename(self.filepath)
                nmesh += 1
        if p.plateau_auto_target:
            p.target_coll = coll
        p.plateau_status = f"外部3Dモデル取込: {nmesh} Mesh / {os.path.basename(self.filepath)}"
        self.report({'INFO'}, p.plateau_status)
        return {'FINISHED'}


class VPOLS_OT_plateau_clear(Operator):
    bl_idname = "vpols.plateau_clear"
    bl_label = "PLATEAUモデルを削除"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        site = active_site(p)
        if site is None:
            return {'CANCELLED'}
        coll = bpy.data.collections.get(plateau_collection_name(site))
        if coll:
            clear_collection(coll)
        p.plateau_status = "PLATEAUモデルを削除しました"
        return {'FINISHED'}


class VPOLS_UL_plateau_bands(UIList):
    def draw_item(self, ctx, layout, data, item, icon, active_data, active_prop, index):
        row = layout.row(align=True)
        row.prop(item, 'min_dist', text='')
        row.label(text='–')
        row.prop(item, 'max_dist', text='')
        row.prop(item, 'lod', text='')


class VPOLS_UL_plateau_files(UIList):
    def draw_item(self, ctx, layout, data, item, icon, active_data, active_prop, index):
        row = layout.row(align=True)
        row.prop(item, 'selected', text='')
        row.label(text=(item.feature_type or 'bldg').upper())
        row.label(text=item.code or '?')
        row.label(text=f"L{item.max_lod}")
        row.label(text=f"{item.file_size/1024/1024:.1f}MB" if item.file_size else '?MB')


# =============================================================================
# SECTION 9.5 : 断面 (縦断 / 横断 / 任意)
#   制限表面と3D都市モデルを同じ切断線で切り、
#     ・制限表面 → 折れ線
#     ・都市モデル → 塗りつぶし面
#     ・任意スパンの垂直離隔 → 寸法線 + 数値
#   として出力する。配置は「現地建て」と「展開図」の2通り。
# =============================================================================

COLL_SEC = "VP_OLS_断面"

_BVH_CACHE = {}   # role → dict(key, bvh, zmin, zmax)


def _coll_cache_key(coll):
    if not coll:
        return None
    objs = [o for o in coll.all_objects if o.type == 'MESH' and not o.hide_viewport]
    return (coll.name, len(objs), sum(len(o.data.vertices) for o in objs))


def build_coll_bvh(context, coll, role, force=False):
    """任意コレクションの BVH をロール別にキャッシュして返す。
    role: 'city' / 'terrain'"""
    key = _coll_cache_key(coll)
    if key is None:
        _BVH_CACHE.pop(role, None)
        return None, 0.0, 0.0
    ent = _BVH_CACHE.get(role)
    if not force and ent and ent['key'] == key and ent['bvh'] is not None:
        return ent['bvh'], ent['zmin'], ent['zmax']

    depsgraph = context.evaluated_depsgraph_get()
    verts, polys = [], []
    zmin, zmax = 1e18, -1e18
    for obj in coll.all_objects:
        if obj.type != 'MESH' or obj.hide_viewport:
            continue
        ev = obj.evaluated_get(depsgraph)
        try:
            me = ev.to_mesh()
        except Exception:
            continue
        if me is None:
            continue
        mw = obj.matrix_world
        base = len(verts)
        for v in me.vertices:
            w = mw @ v.co
            verts.append(w)
            zmin = min(zmin, w.z)
            zmax = max(zmax, w.z)
        for poly in me.polygons:
            idx = [base + i for i in poly.vertices]
            for k in range(1, len(idx) - 1):
                polys.append((idx[0], idx[k], idx[k + 1]))
        ev.to_mesh_clear()

    if not polys:
        _BVH_CACHE.pop(role, None)
        return None, 0.0, 0.0
    bvh = BVHTree.FromPolygons(verts, polys, all_triangles=True, epsilon=0.0)
    _BVH_CACHE[role] = dict(key=key, bvh=bvh, zmin=zmin, zmax=zmax)
    return bvh, zmin, zmax


def city_cache_key(p):
    return _coll_cache_key(p.target_coll)


def build_city_bvh(context, p, force=False):
    return build_coll_bvh(context, p.target_coll, 'city', force)


def build_terrain_bvh(context, p, force=False):
    return build_coll_bvh(context, p.terrain_coll, 'terrain', force)


def ray_hits_vertical(bvh, x, y, z0, z1, max_hits=40):
    """(x,y) の鉛直線が bvh と交わる Z を下から順に列挙する。"""
    hits = []
    z = z0
    d = Vector((0.0, 0.0, 1.0))
    while len(hits) < max_hits and z < z1:
        res = bvh.ray_cast(Vector((x, y, z)), d, z1 - z)
        if not res or res[0] is None:
            break
        zh = res[0].z
        hits.append(zh)
        z = zh + 1e-3
    return hits


# ---------------------------------------------------------------------------
# 切断線の生成
# ---------------------------------------------------------------------------

def _centerline_for(p, idx):
    """アクティブサイトの指定進入方向の中心線を、断面用に細かい刻みで
    再生成する。返す座標はワールド系 (サイト原点を加算済み)。"""
    site = active_site(p)
    if site is None:
        return None, 0.0
    std = STANDARDS[site.standard]
    appr = std['appr']
    dim = compute_facility_dimensions(std, site)
    total_len = approach_total_length(appr)
    dir_p = site.dir1 if idx == 1 else site.dir2
    if not dir_p.enabled:
        return None, total_len
    brg = effective_bearing(site, dir_p.bearing)
    ang = bearing_to_angle(brg)
    if dim['shape'] == 'rect':
        start_front, _ = rect_support(ang, dim['laL'], dim['laW'],
                                      effective_bearing(site, dim['bearing']))
    else:
        start_front = dim['laR']
    plan = direction_plan(dir_p, total_len)
    ox, oy = site.origin_x, site.origin_y
    cl = generate_centerline(ox + math.cos(ang) * start_front,
                             oy + math.sin(ang) * start_front, ang, plan,
                             max(0.5, min(p.sec.step, 5.0)))
    return cl, total_len


def _interp_cl(cl, sta):
    """中心線上の距離 sta における位置と方位。範囲外は端点の接線で外挿する。"""
    if sta <= cl[0]['dist']:
        q = cl[0]
        d = sta - q['dist']
        return (q['x'] + math.cos(q['ang']) * d, q['y'] + math.sin(q['ang']) * d, q['ang'])
    if sta >= cl[-1]['dist']:
        q = cl[-1]
        d = sta - q['dist']
        return (q['x'] + math.cos(q['ang']) * d, q['y'] + math.sin(q['ang']) * d, q['ang'])
    lo, hi = 0, len(cl) - 1
    while hi - lo > 1:
        mid = (lo + hi) // 2
        if cl[mid]['dist'] <= sta:
            lo = mid
        else:
            hi = mid
    a, b = cl[lo], cl[hi]
    t = (sta - a['dist']) / max(1e-9, b['dist'] - a['dist'])
    return (a['x'] + (b['x'] - a['x']) * t,
            a['y'] + (b['y'] - a['y']) * t,
            a['ang'] + (b['ang'] - a['ang']) * t)


def section_line(p):
    """切断線のサンプル列を返す。
    返値: (samples, label)   samples = [(station, x, y), ...]"""
    sc = p.sec
    step = max(0.2, sc.step)
    out = []

    if sc.mode == 'DRAW':
        pts = [(q.x, q.y) for q in sc.draw_points]
        if len(pts) < 2:
            return [], "手描き断面は3Dビューで2点以上を指定してください"
        cum = 0.0
        out.append((0.0, pts[0][0], pts[0][1]))
        for a, b in zip(pts[:-1], pts[1:]):
            dx, dy = b[0] - a[0], b[1] - a[1]
            L = math.hypot(dx, dy)
            if L <= 1e-9:
                continue
            n = max(1, int(math.ceil(L / step)))
            for i in range(1, n + 1):
                t = i / n
                out.append((cum + L * t, a[0] + dx * t, a[1] + dy * t))
            cum += L
        return out, f"手描き断面 頂点{len(pts)}点 L={cum:.1f}m"

    if sc.mode == 'FREE':
        site = active_site(p)
        brg = effective_bearing(site, sc.free_brg) if site else sc.free_brg
        ang = bearing_to_angle(brg)
        n = max(1, int(sc.free_len / step))
        for i in range(n + 1):
            d = sc.free_len * i / n
            out.append((d, sc.free_x + math.cos(ang) * d, sc.free_y + math.sin(ang) * d))
        return out, f"任意断面 方位{sc.free_brg:.0f}° L={sc.free_len:.0f}m"

    site = active_site(p)
    if site is None:
        return [], "サイトがありません (縦断/横断はアクティブサイト基準)"
    cl, total_len = _centerline_for(p, sc.dir_idx)
    if cl is None:
        return [], f"進入方向{sc.dir_idx} が無効です"

    if sc.mode == 'LONG':
        s0, s1 = min(sc.sta_from, sc.sta_to), max(sc.sta_from, sc.sta_to)
        n = max(1, int((s1 - s0) / step))
        for i in range(n + 1):
            sta = s0 + (s1 - s0) * i / n
            x, y, ang = _interp_cl(cl, sta)
            if abs(sc.long_offset) > 1e-9:
                px, py = math.cos(ang + math.pi / 2), math.sin(ang + math.pi / 2)
                x += px * sc.long_offset
                y += py * sc.long_offset
            out.append((sta, x, y))
        return out, (f"縦断 進入{sc.dir_idx} 距離 {s0:.0f}〜{s1:.0f}m"
                     + (f" / 横オフセット {sc.long_offset:+.1f}m" if sc.long_offset else ""))

    # CROSS
    x0, y0, ang = _interp_cl(cl, sc.cross_sta)
    px, py = math.cos(ang + math.pi / 2), math.sin(ang + math.pi / 2)
    n = max(2, int(2 * sc.cross_half / step))
    for i in range(n + 1):
        lat = -sc.cross_half + 2 * sc.cross_half * i / n
        out.append((lat, x0 + px * lat, y0 + py * lat))
    return out, f"横断 進入{sc.dir_idx} 距離{sc.cross_sta:.0f}m 幅±{sc.cross_half:.0f}m"


# ---------------------------------------------------------------------------
# 断面の構築
# ---------------------------------------------------------------------------

def rebuild_section(p):
    sc = p.sec
    coll = get_collection(COLL_SEC)

    samples, label = section_line(p)
    if not samples:
        sc.info = label
        clear_collection(coll)
        return

    ols_bvh, ols_kinds, ols_zmin = build_ols_bvh()
    ctx = bpy.context
    city_bvh, cz0, cz1 = (None, 0.0, 0.0)
    if p.target_coll:
        city_bvh, cz0, cz1 = build_city_bvh(ctx, p)
    terr_bvh, tz0, tz1 = (None, 0.0, 0.0)
    if p.terrain_coll:
        terr_bvh, tz0, tz1 = build_terrain_bvh(ctx, p)

    z_lo_cands = [v for v, ok in ((ols_zmin, ols_bvh), (cz0, city_bvh), (tz0, terr_bvh)) if ok]
    z_hi_cands = [v for v, ok in ((ols_zmin, ols_bvh), (cz1, city_bvh), (tz1, terr_bvh)) if ok]
    z_probe_lo = (min(z_lo_cands) if z_lo_cands else 0.0) - 2000.0
    z_probe_hi = (max(z_hi_cands) if z_hi_cands else 0.0) + 2000.0

    # --- 各測点で 制限表面高 / 地表面高 / モデル交点 を取得 ---
    rows = []
    for sta, x, y in samples:
        sz, fi = (None, None)
        if ols_bvh:
            sz, fi = surface_z_at(ols_bvh, x, y, z_probe_lo)
        hits = ray_hits_vertical(city_bvh, x, y, z_probe_lo, z_probe_hi) if city_bvh else []
        tz = None
        if terr_bvh:
            th = ray_hits_vertical(terr_bvh, x, y, z_probe_lo, z_probe_hi, max_hits=8)
            tz = max(th) if th else None   # 最上面 = 地表面
        rows.append(dict(sta=sta, x=x, y=y, sz=sz, tz=tz,
                         kind=(ols_kinds[fi] if (fi is not None and ols_kinds
                                                 and fi < len(ols_kinds)) else ''),
                         hits=hits, top=(max(hits) if hits else None)))

    # --- 各測点の局所方位 (現地建て時の文字の向き) ---
    for i, r in enumerate(rows):
        a = rows[max(0, i - 1)]
        b = rows[min(len(rows) - 1, i + 1)]
        r['tang'] = math.atan2(b['y'] - a['y'], b['x'] - a['x'])

    # --- 配置関数 ---
    # SHEET : 距離を X 軸に展開した図面。縦倍率が効く
    # INPLACE: 断面をその位置に垂直に立てる。3Dモデルと直接重なるので
    #          縦倍率は使わない (使うとモデルとの整合が崩れる)
    sheet = (sc.placement == 'SHEET')

    def place(r, z):
        if sheet:
            return (sc.sheet_x + r['sta'], sc.sheet_y,
                    sc.sheet_z + (z - sc.fill_floor) * sc.v_exag)
        return (r['x'], r['y'], z)

    def text_rot_for(r):
        return (math.pi / 2, 0.0, 0.0) if sheet else (math.pi / 2, 0.0, r['tang'])

    alive = set()

    # --- 3Dビュー上の切断ラインと垂直ガイド面 ---
    if sc.guide_show and rows:
        guide_z0 = min(sc.fill_floor, cz0 if city_bvh else sc.fill_floor,
                       tz0 if terr_bvh else sc.fill_floor,
                       ols_zmin if ols_bvh else sc.fill_floor) - sc.guide_margin_z
        guide_z1 = max(cz1 if city_bvh else guide_z0 + 100.0,
                       max((r['sz'] for r in rows if r['sz'] is not None), default=guide_z0 + 100.0))
        guide_z1 += sc.guide_margin_z
        gv = [(r['x'], r['y'], guide_z0) for r in rows]
        ge = [(i, i + 1) for i in range(len(gv) - 1)]
        if gv:
            upsert_mesh_object("断面_切断ライン", gv, [], 'sec_ols', coll, 0.0, edges=ge)
            alive.add("断面_切断ライン")
        cv, cf = [], []
        for i in range(len(rows) - 1):
            a, b = rows[i], rows[i + 1]
            base = len(cv)
            cv.extend([(a['x'], a['y'], guide_z0), (a['x'], a['y'], guide_z1),
                       (b['x'], b['y'], guide_z1), (b['x'], b['y'], guide_z0)])
            cf.append((base, base + 1, base + 2, base + 3))
        if cf:
            upsert_mesh_object("断面_ガイド面", cv, cf, 'sec_guide', coll)
            alive.add("断面_ガイド面")

    # --- 制限表面の断面線 ---
    verts, edges = [], []
    prev = None
    for r in rows:
        if r['sz'] is None:
            prev = None
            continue
        verts.append(place(r, r['sz']))
        if prev is not None:
            edges.append((len(verts) - 2, len(verts) - 1))
        prev = r
    if verts:
        upsert_mesh_object("断面_制限表面", verts, [], 'sec_ols', coll, 0.0, edges=edges)
        alive.add("断面_制限表面")

    # --- 底面 (建物底が無い場合に閉じる高さ) ---
    def floor_of(r):
        if sc.floor_mode == 'TERRAIN' and r['tz'] is not None:
            return r['tz']
        return sc.fill_floor

    # --- 都市モデルの断面 (塗り) ---
    def spans_of(r):
        h = r['hits']
        if not h:
            return []
        if sc.fill_mode == 'SOLID':
            lo = min(h) if len(h) >= 2 else floor_of(r)
            return [(lo, max(h))]
        out = []
        hs = sorted(h)
        if len(hs) % 2 == 1:
            hs = [floor_of(r)] + hs
        for i in range(0, len(hs) - 1, 2):
            if hs[i + 1] - hs[i] > 1e-4:
                out.append((hs[i], hs[i + 1]))
        return out

    def split_at(spans, z):
        """スパン列を高さ z で 下側/上側 に分割する。z=None なら全て下側。"""
        if z is None:
            return list(spans), []
        below, above = [], []
        for lo, hi in spans:
            if hi <= z + 1e-6:
                below.append((lo, hi))
            elif lo >= z - 1e-6:
                above.append((lo, hi))
            else:
                below.append((lo, z))
                above.append((z, hi))
        return below, above

    def join_spans(dst_v, dst_f, a, sa, b, sb):
        """隣接測点のスパンを、重なりのある組合せで四角形に結ぶ"""
        for lo1, hi1 in sa:
            for lo2, hi2 in sb:
                if min(hi1, hi2) - max(lo1, lo2) <= 1e-4:
                    continue
                base = len(dst_v)
                dst_v.extend([place(a, lo1), place(a, hi1),
                              place(b, hi2), place(b, lo2)])
                dst_f.append((base, base + 1, base + 2, base + 3))

    fverts, ffaces = [], []        # 制限表面より下 (通常色)
    overts, offaces = [], []       # 制限表面より上 (抵触色)
    for i in range(len(rows) - 1):
        a, b = rows[i], rows[i + 1]
        sa, sb = spans_of(a), spans_of(b)
        if not sa or not sb:
            continue
        if sc.split_over:
            sa_lo, sa_hi = split_at(sa, a['sz'])
            sb_lo, sb_hi = split_at(sb, b['sz'])
            join_spans(fverts, ffaces, a, sa_lo, b, sb_lo)
            join_spans(overts, offaces, a, sa_hi, b, sb_hi)
        else:
            join_spans(fverts, ffaces, a, sa, b, sb)
    if ffaces:
        upsert_mesh_object("断面_都市モデル", fverts, ffaces, 'sec_model', coll, 0.0)
        alive.add("断面_都市モデル")
    if offaces:
        upsert_mesh_object("断面_都市モデル_抵触部", overts, offaces, 'sec_over', coll, 0.0)
        alive.add("断面_都市モデル_抵触部")

    # --- 地表面ライン と 地盤の塗り ---
    t_rows = [r for r in rows if r['tz'] is not None]
    if sc.show_terrain and t_rows:
        tv, te = [], []
        prev = None
        for r in rows:
            if r['tz'] is None:
                prev = None
                continue
            tv.append(place(r, r['tz']))
            if prev is not None:
                te.append((len(tv) - 2, len(tv) - 1))
            prev = r
        if tv:
            upsert_mesh_object("断面_地表面", tv, [], 'sec_terrain', coll, 0.0, edges=te)
            alive.add("断面_地表面")
    if sc.ground_fill and t_rows:
        gbase = min(r['tz'] for r in t_rows) - sc.ground_depth
        gv, gf = [], []
        for i in range(len(rows) - 1):
            a, b = rows[i], rows[i + 1]
            if a['tz'] is None or b['tz'] is None:
                continue
            base = len(gv)
            gv.extend([place(a, gbase), place(a, a['tz']),
                       place(b, b['tz']), place(b, gbase)])
            gf.append((base, base + 1, base + 2, base + 3))
        if gf:
            upsert_mesh_object("断面_地盤", gv, gf, 'sec_ground', coll, 0.0)
            alive.add("断面_地盤")

    # --- 基準線 (地形が無いときの底面高さの目安) ---
    if rows and not t_rows:
        bl = [place(rows[0], sc.fill_floor), place(rows[-1], sc.fill_floor)]
        upsert_mesh_object("断面_基準線", bl, [], 'sec_base', coll, 0.0, edges=[(0, 1)])
        alive.add("断面_基準線")

    # --- 垂直離隔の寸法 ---
    for o in list(coll.objects):
        if o.name.startswith("寸法_"):
            data = o.data
            bpy.data.objects.remove(o, do_unlink=True)
            if data is not None and data.users == 0:
                if isinstance(data, bpy.types.Mesh):
                    bpy.data.meshes.remove(data)
                elif isinstance(data, bpy.types.TextCurve):
                    bpy.data.curves.remove(data)

    n_dim = 0
    worst = None
    dverts, dedges = [], []
    if sc.dim_show and rows:
        s0 = rows[0]['sta']
        next_sta = math.ceil(s0 / sc.dim_span) * sc.dim_span
        for r in rows:
            if n_dim >= sc.dim_max:
                break
            if r['sta'] < next_sta - 1e-6:
                continue
            next_sta += sc.dim_span
            if r['sz'] is None:
                continue
            top = r['top']
            if top is None:
                if sc.dim_only_top:
                    continue
                # モデルが無い測点は地表面 (無ければ一定底面) までの離隔
                top = r['tz'] if r['tz'] is not None else sc.fill_floor
            clr = r['sz'] - top
            if worst is None or clr < worst[0]:
                worst = (clr, r['sta'])
            base = len(dverts)
            dverts.extend([place(r, top), place(r, r['sz'])])
            dedges.append((base, base + 1))
            cu = bpy.data.curves.new(f"寸法_{r['sta']:.0f}", type='FONT')
            cu.body = f"{clr:+.1f}"
            cu.size = sc.dim_text_h
            cu.align_x = 'CENTER'
            cu.align_y = 'BOTTOM'
            tob = bpy.data.objects.new(f"寸法_{r['sta']:.0f}m_{clr:+.1f}m", cu)
            tob.location = place(r, (top + r['sz']) / 2.0)
            tob.rotation_euler = text_rot_for(r)
            tob.color = SURFACE_COLORS['conflict'] if clr < 0 else SURFACE_COLORS['sec_dim']
            cu.materials.append(get_material('conflict' if clr < 0 else 'sec_dim'))
            coll.objects.link(tob)
            n_dim += 1
    if dverts:
        upsert_mesh_object("断面_離隔寸法線", dverts, [], 'sec_dim', coll, 0.0, edges=dedges)
        alive.add("断面_離隔寸法線")

    for nm in ("断面_切断ライン", "断面_ガイド面", "断面_制限表面",
               "断面_都市モデル", "断面_都市モデル_抵触部",
               "断面_地表面", "断面_地盤", "断面_基準線", "断面_離隔寸法線"):
        if nm not in alive:
            o = coll.objects.get(nm)
            if o:
                data = o.data
                bpy.data.objects.remove(o, do_unlink=True)
                if isinstance(data, bpy.types.Mesh) and data.users == 0:
                    bpy.data.meshes.remove(data)

    n_model = sum(1 for r in rows if r['top'] is not None)
    n_terr = sum(1 for r in rows if r['tz'] is not None)
    sc.info = (f"{label} / 測点{len(rows)} (モデル有 {n_model} / 地形有 {n_terr}) / 寸法{n_dim}本"
               + (f" / 最小離隔 {worst[0]:+.2f}m @ {worst[1]:.0f}m" if worst else ""))
    p.sec.info = sc.info



# ---------------------------------------------------------------------------
# 3Dビュー手描き断面ライン
# ---------------------------------------------------------------------------

COLL_SEC_CLIP = "VP_OLS_断面クリップ"
_CUT_PREV = "_vpols_cutaway_prev_hide"
_CUT_ACTIVE = "_vpols_cutaway_hidden"


def _mouse_xy_on_plane(context, event, z_plane):
    """マウス位置を水平面Zへ投影してXYを返す。

    Nパネルのボタンから起動した場合、context.region はUI領域になるため、
    3DビューのWINDOW領域を明示的に取得し、ウィンドウ座標を領域座標へ変換する。
    """
    area = context.area
    region = next((r for r in area.regions if r.type == 'WINDOW'), None) if area else None
    rv3d = context.space_data.region_3d if context.space_data else None
    if region is None or rv3d is None:
        return None
    coord = (event.mouse_x - region.x, event.mouse_y - region.y)
    if coord[0] < 0 or coord[1] < 0 or coord[0] >= region.width or coord[1] >= region.height:
        return None
    origin = view3d_utils.region_2d_to_origin_3d(region, rv3d, coord)
    direction = view3d_utils.region_2d_to_vector_3d(region, rv3d, coord)
    if abs(direction.z) < 1e-10:
        return None
    t = (z_plane - origin.z) / direction.z
    return Vector((origin.x + direction.x * t,
                   origin.y + direction.y * t, z_plane))


def _set_preview_line(points, hover=None, z=0.0):
    coll = get_collection(COLL_SEC)
    seq = list(points)
    if hover is not None:
        seq.append((hover.x, hover.y))
    obj = coll.objects.get("断面_手描きプレビュー")
    if len(seq) < 1:
        if obj:
            data = obj.data
            bpy.data.objects.remove(obj, do_unlink=True)
            if isinstance(data, bpy.types.Mesh) and data.users == 0:
                bpy.data.meshes.remove(data)
        return
    verts = [(x, y, z + 0.15) for x, y in seq]
    edges = [(i, i + 1) for i in range(len(verts) - 1)]
    upsert_mesh_object("断面_手描きプレビュー", verts, [], 'sec_dim', coll,
                       edges=edges)


def _clear_preview_line():
    coll = bpy.data.collections.get(COLL_SEC)
    if not coll:
        return
    obj = coll.objects.get("断面_手描きプレビュー")
    if obj:
        data = obj.data
        bpy.data.objects.remove(obj, do_unlink=True)
        if isinstance(data, bpy.types.Mesh) and data.users == 0:
            bpy.data.meshes.remove(data)


class VPOLS_OT_section_draw(Operator):
    bl_idname = "vpols.section_draw"
    bl_label = "3Dビューで断面ラインを描く"
    bl_description = "左クリックで点を追加し、曲線・折れ線状の断面ラインを設定する"
    bl_options = {'REGISTER', 'UNDO', 'BLOCKING'}

    _points = None
    _old = None
    _hover = None

    @classmethod
    def poll(cls, context):
        return context.area is not None and context.area.type == 'VIEW_3D'

    def invoke(self, context, event):
        p = context.scene.vpols
        self._old = [(q.x, q.y) for q in p.sec.draw_points]
        self._points = []
        self._hover = None
        p.sec.mode = 'DRAW'
        context.window_manager.modal_handler_add(self)
        self.report({'INFO'}, "左クリック: 点追加 / Enter・右クリック: 確定 / Backspace: 戻す / Esc: 取消")
        return {'RUNNING_MODAL'}

    def _finish(self, context):
        p = context.scene.vpols
        if len(self._points) < 2:
            self.report({'WARNING'}, "断面ラインは2点以上必要です")
            return {'RUNNING_MODAL'}
        p.sec.draw_points.clear()
        for x, y in self._points:
            q = p.sec.draw_points.add()
            q.x, q.y = x, y
        _clear_preview_line()
        rebuild_section(p)
        self.report({'INFO'}, p.sec.info)
        return {'FINISHED'}

    def modal(self, context, event):
        p = context.scene.vpols
        z = p.sec.draw_plane_z
        if event.type in {'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}:
            return {'PASS_THROUGH'}
        if event.type == 'MOUSEMOVE':
            hit = _mouse_xy_on_plane(context, event, z)
            if hit is not None:
                self._hover = hit
                _set_preview_line(self._points, hit, z)
                context.area.tag_redraw()
            return {'RUNNING_MODAL'}
        if event.type == 'LEFTMOUSE' and event.value == 'PRESS':
            hit = _mouse_xy_on_plane(context, event, z)
            if hit is not None:
                self._points.append((hit.x, hit.y))
                _set_preview_line(self._points, None, z)
            return {'RUNNING_MODAL'}
        if event.type in {'RET', 'NUMPAD_ENTER', 'RIGHTMOUSE'} and event.value == 'PRESS':
            return self._finish(context)
        if event.type == 'BACK_SPACE' and event.value == 'PRESS':
            if self._points:
                self._points.pop()
            _set_preview_line(self._points, self._hover, z)
            return {'RUNNING_MODAL'}
        if event.type == 'ESC' and event.value == 'PRESS':
            p.sec.draw_points.clear()
            for x, y in self._old:
                q = p.sec.draw_points.add()
                q.x, q.y = x, y
            _clear_preview_line()
            self.report({'INFO'}, "手描き断面を取り消しました")
            return {'CANCELLED'}
        return {'RUNNING_MODAL'}


class VPOLS_OT_section_draw_clear(Operator):
    bl_idname = "vpols.section_draw_clear"
    bl_label = "手描きラインを消去"

    def execute(self, context):
        p = context.scene.vpols
        p.sec.draw_points.clear()
        _clear_preview_line()
        if p.sec.mode == 'DRAW':
            coll = bpy.data.collections.get(COLL_SEC)
            if coll:
                clear_collection(coll)
        p.sec.info = "手描き断面ラインを消去しました"
        return {'FINISHED'}


# ---------------------------------------------------------------------------
# 曲線・折れ線断面に対応する一時カットアウェイ
# ---------------------------------------------------------------------------

def _point_segment_distance(p, a, b):
    dx, dy = b[0] - a[0], b[1] - a[1]
    ll = dx * dx + dy * dy
    if ll <= 1e-18:
        return math.hypot(p[0] - a[0], p[1] - a[1])
    t = max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / ll))
    qx, qy = a[0] + dx * t, a[1] + dy * t
    return math.hypot(p[0] - qx, p[1] - qy)


def _simplify_polyline(points, tol):
    """Ramer-Douglas-Peucker。曲線中心線を軽量な折れ線へ変換する。"""
    if len(points) <= 2:
        return list(points)
    tol = max(0.0, tol)
    keep = {0, len(points) - 1}
    stack = [(0, len(points) - 1)]
    while stack:
        i0, i1 = stack.pop()
        a, b = points[i0], points[i1]
        best_i, best_d = None, -1.0
        for i in range(i0 + 1, i1):
            d = _point_segment_distance(points[i], a, b)
            if d > best_d:
                best_i, best_d = i, d
        if best_i is not None and best_d > tol:
            keep.add(best_i)
            stack.append((i0, best_i))
            stack.append((best_i, i1))
    return [points[i] for i in sorted(keep)]


def _signed_side_polyline(x, y, path):
    """最寄り線分に対する符号付き距離。正=ライン進行方向の左側。

    折れ線の頂点近傍では複数線分が同距離になり、線分の選び方で符号が
    揺れる (凹角部で見え消し側が反転する)。同距離の場合は射影が線分
    内部にあるものを優先し、頂点上で同格なら隣接線分の方向和
    (角の二等分方向) に対する外積で符号を決める。
    """
    best_d2 = 1e300
    best_s = 0.0
    best_interior = False
    best_q = (0.0, 0.0)
    tie_dirs = []
    for a, b in zip(path[:-1], path[1:]):
        dx, dy = b[0] - a[0], b[1] - a[1]
        ll = dx * dx + dy * dy
        if ll <= 1e-18:
            continue
        t_raw = ((x - a[0]) * dx + (y - a[1]) * dy) / ll
        t = max(0.0, min(1.0, t_raw))
        qx, qy = a[0] + dx * t, a[1] + dy * t
        ex, ey = x - qx, y - qy
        d2 = ex * ex + ey * ey
        interior = 1e-9 < t_raw < 1.0 - 1e-9
        s = (dx * (y - a[1]) - dy * (x - a[0])) / math.sqrt(ll)
        if d2 < best_d2 - 1e-9:
            best_d2, best_s, best_interior, best_q = d2, s, interior, (qx, qy)
            tie_dirs = [(dx, dy)]
        elif abs(d2 - best_d2) <= 1e-9:
            tie_dirs.append((dx, dy))
            if interior and not best_interior:
                best_s, best_interior, best_q = s, True, (qx, qy)
    if best_interior or len(tie_dirs) < 2:
        return best_s
    # 頂点上の同格: 方向和 (二等分方向) を基準に外積で判定
    sx = sum(d[0] for d in tie_dirs)
    sy = sum(d[1] for d in tie_dirs)
    n = math.hypot(sx, sy)
    if n < 1e-12:
        return best_s
    return (sx * (y - best_q[1]) - sy * (x - best_q[0])) / n


def _seg_aabb_hit(a, b, x0, x1, y0, y1):
    """線分 a-b が軸平行矩形と交差するか (slab法)"""
    dx, dy = b[0] - a[0], b[1] - a[1]
    t0, t1 = 0.0, 1.0
    for d, lo, hi, o in ((dx, x0, x1, a[0]), (dy, y0, y1, a[1])):
        if abs(d) < 1e-15:
            if o < lo or o > hi:
                return False
            continue
        ta, tb = (lo - o) / d, (hi - o) / d
        if ta > tb:
            ta, tb = tb, ta
        t0, t1 = max(t0, ta), min(t1, tb)
        if t0 > t1:
            return False
    return True


def _bbox_path_crossing(obj, path, offset, eps):
    pts = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
    vals = [_signed_side_polyline(v.x, v.y, path) - offset for v in pts]
    xs, ys = [v.x for v in pts], [v.y for v in pts]
    pad = max(eps, 0.01)
    x0, x1 = min(xs) - pad, max(xs) + pad
    y0, y1 = min(ys) - pad, max(ys) + pad
    # 頂点がbbox内に無くても、長い線分がbboxを貫くケースを線分交差で拾う
    path_inside = (any(x0 <= x <= x1 and y0 <= y <= y1 for x, y in path)
                   or any(_seg_aabb_hit(a, b, x0, x1, y0, y1)
                          for a, b in zip(path[:-1], path[1:])))
    return vals, path_inside


def _clip_polygon(poly, hide_positive, eps):
    """[(Vector, signed_distance), ...] を保持側へクリップする。"""
    def inside(item):
        f = item[1]
        return f <= eps if hide_positive else f >= -eps

    out = []
    if not poly:
        return out
    prev = poly[-1]
    prev_in = inside(prev)
    for cur in poly:
        cur_in = inside(cur)
        if cur_in != prev_in:
            den = prev[1] - cur[1]
            t = 0.5 if abs(den) < 1e-12 else prev[1] / den
            t = max(0.0, min(1.0, t))
            p = prev[0].lerp(cur[0], t)
            out.append((p, 0.0))
        if cur_in:
            out.append(cur)
        prev, prev_in = cur, cur_in
    return out


def _create_clipped_copy(context, obj, path, offset, hide_positive, eps, coll, max_tris):
    depsgraph = context.evaluated_depsgraph_get()
    ev = obj.evaluated_get(depsgraph)
    try:
        me = ev.to_mesh()
    except Exception:
        return None, "評価メッシュ取得失敗"
    if me is None:
        return None, "評価メッシュなし"
    try:
        me.calc_loop_triangles()
        if len(me.loop_triangles) > max_tris:
            return None, f"三角形数{len(me.loop_triangles):,} > 上限{max_tris:,}"
        mw = obj.matrix_world
        wverts = [mw @ v.co for v in me.vertices]
        vals = [_signed_side_polyline(v.x, v.y, path) - offset for v in wverts]
        verts, faces, mats = [], [], []
        for tri in me.loop_triangles:
            poly = [(wverts[i], vals[i]) for i in tri.vertices]
            cp = _clip_polygon(poly, hide_positive, eps)
            if len(cp) < 3:
                continue
            base = len(verts)
            verts.extend([q[0] for q in cp])
            for k in range(1, len(cp) - 1):
                faces.append((base, base + k, base + k + 1))
                mats.append(me.polygons[tri.polygon_index].material_index)
        if not faces:
            return None, "保持側ジオメトリなし"
        new_me = bpy.data.meshes.new(f"CUT_{obj.name}")
        new_me.from_pydata(verts, [], faces)
        new_me.validate(verbose=False)
        new_me.update()
        new_obj = bpy.data.objects.new(f"CUT_{obj.name}", new_me)
        new_obj.matrix_world.identity()
        new_obj["vpols_cutaway_source"] = obj.name
        for mat in obj.data.materials:
            new_me.materials.append(mat)
        if not new_me.materials:
            new_me.materials.append(get_material('sec_clip'))
        for i, poly in enumerate(new_me.polygons):
            if i < len(mats) and len(new_me.materials):
                poly.material_index = min(mats[i], len(new_me.materials) - 1)
        coll.objects.link(new_obj)
        return new_obj, ""
    finally:
        ev.to_mesh_clear()


def _mark_hidden(obj):
    if not obj.get(_CUT_ACTIVE, False):
        obj[_CUT_PREV] = bool(obj.hide_get())
        obj[_CUT_ACTIVE] = True
    obj.hide_set(True)


def restore_section_cutaway():
    for obj in list(bpy.data.objects):
        if obj.get(_CUT_ACTIVE, False):
            try:
                obj.hide_set(bool(obj.get(_CUT_PREV, False)))
            except Exception:
                pass
            for key in (_CUT_PREV, _CUT_ACTIVE):
                try:
                    del obj[key]
                except Exception:
                    pass
    coll = bpy.data.collections.get(COLL_SEC_CLIP)
    if coll:
        clear_collection(coll)
    try:
        bpy.context.scene.vpols.sec.cutaway_active = False
    except Exception:
        pass


def _view_location(context):
    rv3d = context.space_data.region_3d if context.space_data else None
    if rv3d is None:
        return None
    try:
        return rv3d.view_matrix.inverted().translation
    except Exception:
        return None


def apply_section_cutaway(context, p):
    sc = p.sec
    samples, label = section_line(p)
    if len(samples) < 2:
        raise RuntimeError(label)
    restore_section_cutaway()
    raw = [(x, y) for _, x, y in samples]
    path = _simplify_polyline(raw, sc.clip_curve_tol)
    if len(path) < 2:
        raise RuntimeError("有効な断面ラインがありません")

    if sc.clip_hide_side == 'LEFT':
        hide_positive = True
        side_label = "左側"
    elif sc.clip_hide_side == 'RIGHT':
        hide_positive = False
        side_label = "右側"
    else:
        vp = _view_location(context)
        if vp is None:
            raise RuntimeError("3Dビューから実行してください")
        sv = _signed_side_polyline(vp.x, vp.y, path) - sc.clip_offset
        hide_positive = (sv >= 0.0)
        side_label = "現在の視点側（左）" if hide_positive else "現在の視点側（右）"

    targets = []
    if sc.clip_target in {'BOTH', 'CITY'}:
        if not p.target_coll:
            raise RuntimeError("都市モデルのコレクションを指定してください")
        targets.extend([o for o in p.target_coll.all_objects if o.type == 'MESH'])
        if p.terrain_coll:      # 地形も都市側と同じ扱いで見え消し
            targets.extend([o for o in p.terrain_coll.all_objects if o.type == 'MESH'])
    if sc.clip_target in {'BOTH', 'OLS'}:
        oc = bpy.data.collections.get(COLL_SITES)
        if oc:
            targets.extend([o for o in oc.all_objects if o.type == 'MESH' and
                            o.get("vpols_kind") in SURFACE_KINDS])
    # 重複除去。断面出力・既存クリップ複製は対象外。
    uniq = []
    seen = set()
    for o in targets:
        if o.name in seen or o.name.startswith("CUT_"):
            continue
        seen.add(o.name)
        uniq.append(o)

    clip_coll = get_collection(COLL_SEC_CLIP)
    n_hide = n_clip = n_keep = 0
    warnings = []
    eps = sc.clip_epsilon
    for obj in uniq:
        if obj.hide_viewport or obj.hide_get():
            continue
        vals, path_inside = _bbox_path_crossing(obj, path, sc.clip_offset, eps)
        all_pos = all(v > eps for v in vals)
        all_neg = all(v < -eps for v in vals)
        if not path_inside and (all_pos or all_neg):
            on_hidden_side = (all_pos and hide_positive) or (all_neg and not hide_positive)
            if on_hidden_side:
                _mark_hidden(obj)
                n_hide += 1
            else:
                n_keep += 1
            continue

        clipped, warn = _create_clipped_copy(
            context, obj, path, sc.clip_offset, hide_positive, eps,
            clip_coll, sc.clip_max_tris)
        if clipped is not None:
            _mark_hidden(obj)
            n_clip += 1
        else:
            # 境界物件を処理できないときは、安全側として元形状を表示したままにする。
            n_keep += 1
            if warn:
                warnings.append(f"{obj.name}: {warn}")

    sc.cutaway_active = True
    sc.clip_info = (f"{label} / {side_label}を見え消し / 完全非表示{n_hide} / "
                    f"境界クリップ{n_clip} / 表示維持{n_keep} / 曲線折れ線{len(path)}点")
    if warnings:
        sc.clip_info += f" / 省略{len(warnings)}件"
    return warnings


class VPOLS_OT_section_cutaway_apply(Operator):
    bl_idname = "vpols.section_cutaway_apply"
    bl_label = "現在の断面ラインで見え消し"
    bl_description = "曲線・折れ線断面を境界に、視点側または指定側を一時的に非表示にする"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        try:
            warnings = apply_section_cutaway(context, p)
        except Exception as e:
            self.report({'ERROR'}, str(e))
            return {'CANCELLED'}
        self.report({'WARNING'} if warnings else {'INFO'}, p.sec.clip_info)
        return {'FINISHED'}


class VPOLS_OT_section_cutaway_restore(Operator):
    bl_idname = "vpols.section_cutaway_restore"
    bl_label = "見え消しを解除"
    bl_description = "一時クリップを削除し、元の都市モデルとOLS表示を復元する"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        restore_section_cutaway()
        p = context.scene.vpols
        p.sec.clip_info = "カットアウェイ表示を解除しました"
        self.report({'INFO'}, p.sec.clip_info)
        return {'FINISHED'}


class VPOLS_OT_section_build(Operator):
    bl_idname = "vpols.section_build"
    bl_label = "断面を生成 / 更新"
    bl_options = {'REGISTER', 'UNDO'}

    def execute(self, context):
        p = context.scene.vpols
        rebuild_section(p)
        self.report({'INFO'}, p.sec.info)
        return {'FINISHED'}


class VPOLS_OT_section_cache(Operator):
    bl_idname = "vpols.section_cache"
    bl_label = "都市モデルのキャッシュを再構築"
    bl_description = "都市モデルを差し替え・移動した後に実行する"

    def execute(self, context):
        p = context.scene.vpols
        if not p.target_coll:
            self.report({'ERROR'}, "都市モデルのコレクションを指定してください")
            return {'CANCELLED'}
        bvh, z0, z1 = build_city_bvh(context, p, force=True)
        if bvh is None:
            self.report({'WARNING'}, "都市モデルのメッシュが見つかりません")
            return {'CANCELLED'}
        msg = f"都市モデル (Z {z0:.1f}〜{z1:.1f}m)"
        if p.terrain_coll:
            tb, t0, t1 = build_terrain_bvh(context, p, force=True)
            msg += f" / 地形 (Z {t0:.1f}〜{t1:.1f}m)" if tb else " / 地形メッシュなし"
        self.report({'INFO'}, "キャッシュ再構築: " + msg)
        rebuild_section(p)
        return {'FINISHED'}


class VPOLS_OT_section_from_cursor(Operator):
    bl_idname = "vpols.section_from_cursor"
    bl_label = "3Dカーソルを任意断面の始点に"

    def execute(self, context):
        p = context.scene.vpols
        c = context.scene.cursor.location
        p.sec.free_x, p.sec.free_y = c.x, c.y
        p.sec.mode = 'FREE'
        rebuild_section(p)
        self.report({'INFO'}, f"始点 ({c.x:.1f}, {c.y:.1f})")
        return {'FINISHED'}


class VPOLS_OT_section_at_result(Operator):
    bl_idname = "vpols.section_at_result"
    bl_label = "選択中の抵触箇所で断面を切る"
    bl_description = "抵触箇所の代表点を通る任意断面 (進入方位に直交) を設定する"

    def execute(self, context):
        p = context.scene.vpols
        if not (0 <= p.result_index < len(p.results)):
            self.report({'ERROR'}, "抵触箇所を選択してください")
            return {'CANCELLED'}
        it = p.results[p.result_index]
        site = active_site(p)
        if site is None:
            self.report({'ERROR'}, "サイトがありません")
            return {'CANCELLED'}
        dir_p = site.dir1 if p.sec.dir_idx == 1 else site.dir2
        brg = (effective_bearing(site, dir_p.bearing) + 90.0) % 360.0
        L = p.sec.free_len
        ang = bearing_to_angle(brg)
        p.sec.mode = 'FREE'
        p.sec.free_brg = brg
        p.sec.free_x = it.rx - math.cos(ang) * L / 2.0
        p.sec.free_y = it.ry - math.sin(ang) * L / 2.0
        rebuild_section(p)
        self.report({'INFO'}, f"抵触箇所 No.{it.rank} で断面設定 / {p.sec.info}")
        return {'FINISHED'}


class VPOLS_OT_section_csv(Operator, ExportHelper):
    bl_idname = "vpols.section_csv"
    bl_label = "断面の離隔表をCSV出力"
    filename_ext = ".csv"
    filter_glob: StringProperty(default="*.csv", options={'HIDDEN'})

    def execute(self, context):
        p = context.scene.vpols
        samples, label = section_line(p)
        if not samples:
            self.report({'ERROR'}, label)
            return {'CANCELLED'}
        ols_bvh, kinds, ols_zmin = build_ols_bvh()
        city_bvh, cz0, cz1 = build_city_bvh(context, p) if p.target_coll else (None, 0, 0)
        terr_bvh, tz0, tz1 = build_terrain_bvh(context, p) if p.terrain_coll else (None, 0, 0)
        cands_lo = [v for v, ok in ((ols_zmin, ols_bvh), (cz0, city_bvh), (tz0, terr_bvh)) if ok]
        cands_hi = [v for v, ok in ((ols_zmin, ols_bvh), (cz1, city_bvh), (tz1, terr_bvh)) if ok]
        lo = (min(cands_lo) if cands_lo else 0.0) - 2000.0
        hi = (max(cands_hi) if cands_hi else 0.0) + 2000.0

        path = self.filepath if self.filepath.lower().endswith(".csv") else self.filepath + ".csv"
        span = max(0.01, p.sec.dim_span)
        nxt = math.ceil(samples[0][0] / span) * span
        with open(path, "w", newline="", encoding="utf-8-sig") as fp:
            w = csv.writer(fp)
            w.writerow(["断面", label])
            w.writerow(["距離[m]", "X[m]", "Y[m]", "制限表面高[m]", "支配面",
                        "地表面高[m]", "モデル頂部[m]", "モデル底部[m]",
                        "対地高さ[m]", "垂直離隔[m]", "判定"])
            n = 0
            for sta, x, y in samples:
                if sta < nxt - 1e-6:
                    continue
                nxt += span
                sz, fi = surface_z_at(ols_bvh, x, y, lo) if ols_bvh else (None, None)
                hits = ray_hits_vertical(city_bvh, x, y, lo, hi) if city_bvh else []
                top = max(hits) if hits else None
                bot = min(hits) if hits else None
                tz = None
                if terr_bvh:
                    th = ray_hits_vertical(terr_bvh, x, y, lo, hi, max_hits=8)
                    tz = max(th) if th else None
                # 垂直離隔: 障害物頂部が無ければ地表面に対して算定
                ref = top if top is not None else tz
                agl = (top - tz) if (top is not None and tz is not None) else None
                clr = (sz - ref) if (sz is not None and ref is not None) else None
                w.writerow([f"{sta:.2f}", f"{x:.3f}", f"{y:.3f}",
                            "" if sz is None else f"{sz:.3f}",
                            (kinds[fi] if (fi is not None and fi < len(kinds)) else ""),
                            "" if tz is None else f"{tz:.3f}",
                            "" if top is None else f"{top:.3f}",
                            "" if bot is None else f"{bot:.3f}",
                            "" if agl is None else f"{agl:.3f}",
                            "" if clr is None else f"{clr:.3f}",
                            "" if clr is None else ("抵触" if clr < 0 else "適合")])
                n += 1
        self.report({'INFO'}, f"CSV出力: {os.path.basename(path)} ({n}測点)")
        return {'FINISHED'}


# =============================================================================
# SECTION 10 : UI
# =============================================================================

class VPOLS_UL_results(UIList):
    def draw_item(self, ctx, layout, data, item, icon, active_data, active_prop, index):
        row = layout.row(align=True)
        row.label(text=f"{item.rank:03d}")
        # 同一物件が複数箇所を持つ場合は「物件名 #n/N」と表示して区別する
        tag = f" #{item.spot_no}/{item.spots_in_obj}" if item.spots_in_obj > 1 else ""
        row.label(text=f"{item.obj_name}{tag}")
        row.label(text=f"+{item.max_excess:.2f}m")
        row.label(text=item.surface)


def draw_direction(layout, p, idx, dp, total_len):
    box = layout.box()
    hdr = box.row(align=True)
    hdr.prop(dp, "enabled", text=f"進入方向 {idx}")
    if not dp.enabled:
        return
    c = box.column(align=True)
    c.prop(dp, "bearing")
    c.prop(dp, "trans_side")
    box.prop(dp, "use_segments", toggle=True)

    if dp.use_segments:
        used = 0.0
        for i in range(1, 7):
            t = getattr(dp, f"seg{i}_type")
            r = box.row(align=True)
            r.prop(dp, f"seg{i}_type", text="")
            if t == 'skip':
                continue
            r.prop(dp, f"seg{i}_len", text="L")
            if t in ('left', 'right'):
                r.prop(dp, f"seg{i}_r", text="R")
            used += getattr(dp, f"seg{i}_len")
        rem = total_len - used
        rw = box.row(align=True)
        rw.prop(dp, "tail_mode", text="残り")
        if dp.tail_mode in ('left', 'right'):
            rw.prop(dp, "tail_r", text="R")
        box.label(text=f"区間計 {used:.0f}m / 全長 {total_len:.0f}m → 残 {rem:.0f}m",
                  icon='CHECKMARK' if rem >= -0.5 else 'ERROR')
    else:
        c = box.column(align=True)
        c.prop(dp, "straight0")
        c.prop(dp, "turn")
        if dp.turn != 'none':
            c.prop(dp, "radius")
            c.prop(dp, "turn_deg")
            c.prop(dp, "curves")
            if dp.curves > 1:
                c.prop(dp, "inter_s")
            arc = dp.radius * math.radians(dp.turn_deg) * dp.curves
            box.label(text=f"曲線延長 {arc:.0f}m / 直線 {dp.straight0:.0f}m "
                           f"→ 残 {max(0.0, total_len - arc - dp.straight0):.0f}m",
                      icon='CHECKMARK' if dp.straight0 + arc <= total_len else 'ERROR')


class VPOLS_PT_base(Panel):
    bl_space_type = 'VIEW_3D'
    bl_region_type = 'UI'
    bl_category = "VP-OLS"


class VPOLS_PT_exchange(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_exchange"
    bl_label = "1. HTML連携データ読込"

    def draw(self, context):
        p = context.scene.vpols
        L = self.layout
        if not hasattr(p, "exchange_ref_valid"):
            b = L.box(); b.label(text="VP-OLS の登録スキーマが旧版のままです", icon='ERROR')
            b.label(text="旧版を無効化 → Blender再起動 → v0.8.6のみ有効化")
            return

        g = L.box()
        g.label(text="最初にここから開始", icon='INFO')
        g.label(text="HTML Viewerの「Blender連携出力」で作成した .vpols.json を読み込みます。")
        g.label(text="FATO位置・標高・OLS諸元・曲線・PLATEAU検索座標を一括反映します。")
        row = g.row(align=True)
        row.scale_y = 1.4
        row.operator("vpols.import_exchange", text="HTML出力結果を読み込む", icon='IMPORT')
        row.operator("vpols.export_exchange", text="Blenderから書き戻す", icon='EXPORT')

        if len(p.sites) == 0:
            L.box().label(text="サイトが0件でも、そのままHTML交換JSONを読み込めます", icon='CHECKMARK')
        if p.summary:
            L.box().label(text=p.summary[:180], icon='INFO')

        b = L.box()
        b.label(text="交換座標基準（確認用）", icon='WORLD')
        b.prop(p, "exchange_ref_valid")
        if p.exchange_ref_valid:
            c=b.column(align=True)
            c.label(text=f"EPSG:{p.exchange_epsg or '未設定'} / 平面直角系 {p.exchange_zone or '-'}")
            c.label(text=f"基準緯度経度: {p.exchange_origin_lat:.7f}, {p.exchange_origin_lng:.7f}")
            c.label(text=f"標高基準: {p.exchange_vertical_datum} / 子午線収差 {p.exchange_convergence:+.6f}°")



class VPOLS_PT_surface(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_surface"
    bl_label = "3. 制限表面の諸元設定"

    def draw(self, context):
        p=context.scene.vpols; L=self.layout; s=active_site(p)
        info=L.box(); info.label(text="FATO寸法・進入表面・転移表面・PSSを設定", icon='MESH_GRID')
        info.label(text="HTMLから読込済みの場合は値を確認し、必要な箇所だけ微調整してください。")
        if s is None:
            L.label(text="先に 1～2 でFATOサイトを作成してください", icon='INFO'); return
        L.prop(p,'live_update',toggle=True,icon='PLAY')
        h=L.box(); h.label(text=f"対象: {s.name}",icon='CHECKMARK'); h.prop(s,'standard',text='適用規格'); h.label(text=STANDARDS[s.standard]['info'])
        b=L.box(); b.label(text="FATO / TLOF / 安全区域",icon='MESH_CIRCLE')
        c=b.column(align=True); c.prop(s,'fato_d'); c.prop(s,'afm_required'); c.prop(s,'fato_shape')
        if s.fato_shape=='rect':
            c.prop(s,'fato_bearing'); c.prop(s,'rect_fato_l'); c.prop(s,'rect_fato_w'); c.prop(s,'rect_tlof_l'); c.prop(s,'rect_tlof_w')
        c.prop(s,'sa_margin_manual'); r=c.row(align=True); r.prop(s,'elevated',toggle=True); r.prop(s,'night',toggle=True)
        std=STANDARDS[s.standard]; dim=compute_facility_dimensions(std,s)
        b.label(text=f"着陸帯 φ{dim['laD']:.2f}m / SA幅 {dim['saMarg']:.2f}m"); b.operator('vpols.check_landing_area',icon='CHECKMARK')

        b=L.box(); b.label(text="表面共通諸元",icon='MOD_SIMPLEDEFORM')
        c=b.column(align=True); c.prop(s,'slope'); c.prop(s,'trans_h'); c.prop(s,'trans_slope'); c.prop(s,'make_inner'); c.prop(s,'pss_apply')
        if s.pss_apply: c.prop(s,'pss_h'); c.prop(s,'pss_slope')
        total_len=approach_total_length(std['appr'])
        draw_direction(L,s,1,s.dir1,total_len); draw_direction(L,s,2,s.dir2,total_len)
        row=L.row(align=True); row.scale_y=1.3; row.operator('vpols.generate',text='アクティブFATOを再生成',icon='MESH_GRID'); row.operator('vpols.generate_all',text='全FATOを再生成',icon='FILE_REFRESH')
        if p.geo_info: L.box().label(text=p.geo_info,icon='INFO')
        if p.warn: L.box().label(text=p.warn,icon='ERROR')



class VPOLS_PT_datum(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_datum"
    bl_label = "2. FATO座標・標高"

    def draw(self, context):
        p=context.scene.vpols; L=self.layout
        info=L.box(); info.label(text="FATOの位置と高さを確認", icon='EMPTY_AXIS')
        info.label(text="緯度・経度はPLATEAU検索中心、T.P./AMSLはCityGMLとの高さ整合に使用します。")

        row=L.row(); row.template_list("VPOLS_UL_sites", "", p, "sites", p, "site_index", rows=3)
        col=row.column(align=True); col.operator("vpols.site_add", text="", icon='ADD'); col.operator("vpols.site_remove", text="", icon='REMOVE'); col.operator("vpols.site_duplicate", text="", icon='DUPLICATE')
        s=active_site(p)
        if s is None:
            L.label(text="HTML交換JSONを読み込むか、「+」でサイトを作成してください", icon='INFO'); return

        b=L.box(); b.label(text=f"アクティブFATO: {s.name}", icon='WORLD')
        c=b.column(align=True); c.prop(s,'geo_lat'); c.prop(s,'geo_lon')
        b.label(text="※ HTML交換読込時はFATOごとの地理座標を自動反映")

        b=L.box(); b.label(text="Blenderローカル原点", icon='EMPTY_AXIS')
        c=b.column(align=True); c.prop(s,'origin_x'); c.prop(s,'origin_y'); c.prop(s,'origin_z')
        b.operator('vpols.site_origin_cursor', text='3Dカーソル位置を原点へ反映', icon='CURSOR')

        b=L.box(); b.label(text="高さ基準", icon='EMPTY_SINGLE_ARROW')
        c=b.column(align=True); c.prop(s,'z_datum'); c.prop(s,'site_elev'); c.prop(s,'pad_h'); c.prop(s,'ols_base_h')
        b.label(text=f"OLS基面 = 原点Z {s.origin_z:.2f} + オフセット {base_amsl(s):.2f} m")

        b=L.box(); b.label(text="座標・作図詳細（通常は自動値で可）", icon='ORIENTATION_GLOBAL')
        c=b.column(align=True); c.prop(s,'use_grid_north')
        if s.use_grid_north: c.prop(s,'convergence')
        c.prop(s,'cl_step'); c.prop(s,'trans_lat_div')
        row=b.row(align=True); row.operator('vpols.import_vpsite', text='.vpsite.json読込', icon='IMPORT'); row.operator('vpols.align_city', text='都市モデル位置合わせ', icon='ORIENTATION_GLOBAL')



class VPOLS_PT_check(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_check"
    bl_label = "5. 抵触照査・垂直離隔"

    def draw(self, context):
        p=context.scene.vpols; L=self.layout
        info=L.box(); info.label(text='PLATEAU等の都市モデルとOLSの抵触を解析',icon='VIEWZOOM')
        info.label(text='都市モデル=障害物、地形(DEM)=地表面として分離して指定します。')
        L.prop(p,'target_coll'); L.prop(p,'terrain_coll'); L.prop(p,'threshold')
        b=L.box(); b.label(text='解析精度・サンプリング',icon='PARTICLES')
        c=b.column(align=True); c.prop(p,'sample_step'); c.prop(p,'sample_edge'); c.prop(p,'sample_grid'); c.prop(p,'sample_face'); c.prop(p,'max_samples_per_obj')
        b=L.box(); b.label(text='突出領域の分離',icon='STICKY_UVS_DISABLE')
        c=b.column(align=True); c.prop(p,'spot_link'); c.prop(p,'min_spot_points'); c.prop(p,'rep_mode')
        if p.spot_link < p.sample_step*1.5: b.label(text='分離距離がサンプル間隔に近すぎます',icon='ERROR')
        c=L.column(align=True); c.prop(p,'make_markers'); c.prop(p,'make_point_cloud')
        row=L.row(align=True); row.scale_y=1.4; row.operator('vpols.analyze',text='抵触照査を実行',icon='VIEWZOOM'); row.operator('vpols.probe',text='カーソル位置照査',icon='DRIVER_DISTANCE')
        if p.summary: L.box().label(text=p.summary[:180],icon='INFO')
        if len(p.results):
            L.label(text=f"抵触箇所 {len(p.results)}件"); L.template_list('VPOLS_UL_results','',p,'results',p,'result_index',rows=10)
            if 0<=p.result_index<len(p.results):
                it=p.results[p.result_index]; c=L.box().column(align=True); c.label(text=f"物件: {it.obj_name}"); c.label(text=f"箇所: {it.spot_no}/{it.spots_in_obj} / 支配面: {it.surface}"); c.label(text=f"最大超過: {it.max_excess:+.2f}m / 最小垂直離隔: {it.obj_min_clearance:+.2f}m"); c.label(text=f"代表点Z {it.rz:.2f}m / 面高 {it.surf_z:.2f}m / 最高点 {it.peak_z:.2f}m")
            row=L.row(align=True); row.operator('vpols.select_result',icon='RESTRICT_SELECT_OFF'); row.operator('vpols.export_csv',icon='EXPORT')
            L.prop(p,'slab_thickness'); L.operator('vpols.extract',icon='MOD_BOOLEAN')




class VPOLS_PT_plateau(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_plateau"
    bl_label = "4. PLATEAU 建築物・DEM読込"

    def draw(self, context):
        p=context.scene.vpols; L=self.layout
        if not hasattr(p,'plateau_lod_bands'):
            b=L.box(); b.label(text='VP-OLS登録スキーマが旧版/別版です',icon='ERROR'); b.label(text='旧版を無効化 → Blender再起動 → v0.8.6のみ有効化'); return
        site=active_site(p)
        if site is None:
            L.label(text='先にHTML交換JSONを読み込むかFATOサイトを作成してください',icon='INFO'); return

        g=L.box(); g.label(text='PLATEAU操作手順',icon='INFO')
        g.label(text='① 座標確認 → ② API/取得範囲設定 → ③ 検索 → ④ ファイル選択 → ⑤ 取得開始')
        g.label(text='建築物=bldg / 地形=dem。取得後は照査用Collectionへ自動設定できます。')

        b=L.box(); b.label(text='① PLATEAU検索中心',icon='WORLD')
        c=b.column(align=True); c.prop(site,'geo_lat'); c.prop(site,'geo_lon')
        b.label(text=f"現在: {site.geo_lat:.7f}, {site.geo_lon:.7f} / {site.name}")

        b=L.box(); b.label(text='② API・取得範囲',icon='URL')
        c=b.column(align=True); c.prop(p,'plateau_radius'); c.prop(p,'plateau_query_margin')
        c.label(text='検索余裕はAPI候補抽出用。実際のMesh取込判定は取得半径で行います。')
        c.prop(p,'plateau_max_download_mb'); c.prop(p,'plateau_timeout'); c.prop(p,'plateau_cache_dir'); c.prop(p,'plateau_use_cache')
        row=b.row(align=True); row.scale_y=1.2; row.operator('vpols.plateau_api_test',text='API接続テスト',icon='CHECKMARK')

        # 共通進捗ボックス
        if p.plateau_progress_active or p.plateau_progress_phase not in {'','待機'}:
            pb=L.box(); pb.label(text='PLATEAU取得進捗',icon='TIME')
            r=pb.row(); r.enabled=False; r.prop(p,'plateau_progress_overall',text=f"全体 {p.plateau_progress_overall*100:.0f}%",slider=True)
            if p.plateau_progress_file_total:
                pb.label(text=f"ファイル {p.plateau_progress_file_index}/{p.plateau_progress_file_total}  |  {p.plateau_progress_phase}")
            if p.plateau_progress_file_name: pb.label(text=f"現在: {p.plateau_progress_file_name}")
            r=pb.row(); r.enabled=False; r.prop(p,'plateau_progress_file',text=f"現在ファイル {p.plateau_progress_file*100:.0f}%",slider=True)
            if p.plateau_progress_total_mb>0:
                pb.label(text=f"DL {p.plateau_progress_current_mb:.1f} / {p.plateau_progress_total_mb:.1f} MB")
            elif p.plateau_progress_current_mb>0:
                pb.label(text=f"DL {p.plateau_progress_current_mb:.1f} MB")
            if p.plateau_progress_processed_label: pb.label(text=p.plateau_progress_processed_label)
            if p.plateau_progress_detail: pb.label(text=p.plateau_progress_detail[:180])
            if p.plateau_progress_active:
                rr=pb.row(); rr.alert=True; rr.operator('vpols.plateau_cancel_job',text='取得をキャンセル',icon='CANCEL')
                pb.label(text='※ DL中は即時停止。XML解析/Mesh生成中は現在ファイル完了後に停止する場合があります。')

        b=L.box(); b.label(text='③ 建築物 bldg：距離LOD設定',icon='HOME')
        row=b.row(); row.template_list('VPOLS_UL_plateau_bands','',p,'plateau_lod_bands',p,'plateau_lod_index',rows=max(3,min(7,len(p.plateau_lod_bands))))
        col=row.column(align=True); col.operator('vpols.plateau_band_add',text='',icon='ADD'); col.operator('vpols.plateau_band_remove',text='',icon='REMOVE')
        b.operator('vpols.plateau_band_default',text='標準LOD帯へ戻す',icon='PRESET')
        ok,msg=_plateau_band_validate(p)
        if not ok: b.label(text=msg,icon='ERROR')
        c=b.column(align=True); c.prop(p,'plateau_fallback_lower'); c.prop(p,'plateau_max_buildings'); c.prop(p,'plateau_replace'); c.prop(p,'plateau_auto_target')
        row=b.row(align=True); row.scale_y=1.25; row.operator('vpols.plateau_search',text='建築物を範囲検索',icon='VIEWZOOM')
        if len(p.plateau_files):
            b.template_list('VPOLS_UL_plateau_files','BLDG',p,'plateau_files',p,'plateau_file_index',rows=min(7,len(p.plateau_files)))
            total=sum(x.file_size for x in p.plateau_files if x.selected); cnt=sum(1 for x in p.plateau_files if x.selected)
            b.label(text=f"選択 {cnt}件 / 取得予定 約{total/1024/1024:.1f}MB")
            r=b.row(); r.enabled=not p.plateau_progress_active; r.scale_y=1.4; r.operator('vpols.plateau_import_selected',text='建築物の取得を開始',icon='IMPORT')
        if p.plateau_status: b.label(text=p.plateau_status[:180],icon='INFO')

        db=L.box(); db.label(text='④ 地形 DEM：TIN設定',icon='MESH_GRID')
        db.label(text='OLS断面・地表面高の照査に使用。建築物とは別Collectionで管理します。')
        db.prop(p,'plateau_dem_max_triangles')
        db.label(text='※ この値は選択DEM全体の三角形上限。到達時は残りファイルを処理しません。')
        row=db.row(align=True); row.prop(p,'plateau_dem_replace'); row.prop(p,'plateau_auto_terrain')
        row=db.row(align=True); row.scale_y=1.25; row.operator('vpols.plateau_dem_search',text='DEMを範囲検索',icon='VIEWZOOM'); row.operator('vpols.plateau_dem_clear',text='DEM削除',icon='TRASH')
        if len(p.plateau_dem_files):
            db.template_list('VPOLS_UL_plateau_files','DEM',p,'plateau_dem_files',p,'plateau_dem_file_index',rows=min(6,len(p.plateau_dem_files)))
            total=sum(x.file_size for x in p.plateau_dem_files if x.selected); cnt=sum(1 for x in p.plateau_dem_files if x.selected)
            db.label(text=f"DEM選択 {cnt}件 / 取得予定 約{total/1024/1024:.1f}MB")
            r=db.row(); r.enabled=not p.plateau_progress_active; r.scale_y=1.4; r.operator('vpols.plateau_dem_import_selected',text='DEMの取得を開始',icon='IMPORT')
        if p.plateau_dem_status: db.label(text=p.plateau_dem_status[:180],icon='INFO')

        b=L.box(); b.label(text='ローカルCityGML / 変換済みモデル（補助）',icon='FILE_FOLDER')
        row=b.row(align=True); row.operator('vpols.plateau_import_gml',text='bldg GML',icon='HOME'); row.operator('vpols.plateau_import_dem_gml',text='dem GML',icon='MESH_GRID')
        b.operator('vpols.plateau_import_mesh',text='OBJ / FBX / glTFを直接取込',icon='MESH_DATA'); b.operator('vpols.plateau_clear',text='PLATEAU建築物を削除',icon='TRASH')
        if p.plateau_last_error:
            eb=L.box(); eb.label(text='PLATEAU診断',icon='ERROR'); eb.label(text=p.plateau_last_error[:180])
        if p.plateau_last_url:
            ub=L.box(); ub.label(text='最終API URL',icon='URL'); ub.label(text=p.plateau_last_url[:180])



class VPOLS_PT_section(VPOLS_PT_base):
    bl_idname = "VPOLS_PT_section"
    bl_label = "6. 断面解析 (縦断 / 横断 / 任意)"

    def draw(self, context):
        p = context.scene.vpols
        sc = p.sec
        L = self.layout
        info = L.box()
        info.label(text="制限表面・建築物・DEMを同じ切断線で確認", icon='MOD_SOLIDIFY')
        info.label(text="抵触箇所を選択後、その位置で断面を作ることもできます。")
        L.prop(sc, "live", toggle=True, icon='PLAY')
        L.prop(sc, "mode", text="")

        b = L.box()
        if sc.mode == 'LONG':
            c = b.column(align=True)
            c.prop(sc, "dir_idx")
            c.prop(sc, "long_offset")
            c.prop(sc, "sta_from")
            c.prop(sc, "sta_to")
        elif sc.mode == 'CROSS':
            c = b.column(align=True)
            c.prop(sc, "dir_idx")
            c.prop(sc, "cross_sta")
            c.prop(sc, "cross_half")
        elif sc.mode == 'FREE':
            c = b.column(align=True)
            c.prop(sc, "free_x")
            c.prop(sc, "free_y")
            c.prop(sc, "free_brg")
            c.prop(sc, "free_len")
            b.operator("vpols.section_from_cursor", icon='CURSOR')
            b.operator("vpols.section_at_result", icon='TRACKING')
        else:
            c = b.column(align=True)
            c.prop(sc, "draw_plane_z")
            c.label(text=f"指定点: {len(sc.draw_points)}点")
            row = c.row(align=True)
            row.operator("vpols.section_draw", icon='GREASEPENCIL')
            row.operator("vpols.section_draw_clear", text="消去", icon='TRASH')
            c.label(text="左クリックで点追加、Enter/右クリックで確定", icon='INFO')
        b.prop(sc, "step")
        b.prop(sc, "guide_show")
        if sc.guide_show:
            b.prop(sc, "guide_margin_z")

        b = L.box()
        b.label(text="都市モデル・地形の断面", icon='MOD_SOLIDIFY')
        c = b.column(align=True)
        c.prop(p, "terrain_coll")
        c.separator()
        c.prop(sc, "fill_mode")
        c.prop(sc, "floor_mode")
        c.prop(sc, "fill_floor")
        c.prop(sc, "split_over")
        c.prop(sc, "show_terrain")
        c.prop(sc, "ground_fill")
        if sc.ground_fill:
            c.prop(sc, "ground_depth")
        b.operator("vpols.section_cache", icon='FILE_REFRESH')

        b = L.box()
        b.label(text="配置", icon='OBJECT_ORIGIN')
        c = b.column(align=True)
        c.prop(sc, "placement")
        if sc.placement == 'SHEET':
            c.prop(sc, "sheet_x")
            c.prop(sc, "sheet_y")
            c.prop(sc, "sheet_z")
            c.prop(sc, "v_exag")
        else:
            b.label(text="現地建てでは縦倍率は無効 (モデルとの整合を保つため)", icon='INFO')

        b = L.box()
        b.label(text="垂直離隔の寸法", icon='DRIVER_DISTANCE')
        b.prop(sc, "dim_show")
        if sc.dim_show:
            c = b.column(align=True)
            c.prop(sc, "dim_span")
            c.prop(sc, "dim_text_h")
            c.prop(sc, "dim_max")
            c.prop(sc, "dim_only_top")

        b = L.box()
        b.label(text="3Dカットアウェイ（見え消し）", icon='XRAY')
        c = b.column(align=True)
        c.prop(sc, "clip_target")
        c.prop(sc, "clip_hide_side")
        c.prop(sc, "clip_offset")
        c.prop(sc, "clip_curve_tol")
        c.prop(sc, "clip_epsilon")
        c.prop(sc, "clip_max_tris")
        row = b.row(align=True)
        row.operator("vpols.section_cutaway_apply", icon='HIDE_ON')
        row.operator("vpols.section_cutaway_restore", text="解除", icon='HIDE_OFF')
        if sc.clip_info:
            b.label(text=sc.clip_info, icon='INFO')

        L.operator("vpols.section_build", icon='MOD_LINEART')
        L.operator("vpols.section_csv", icon='EXPORT')
        if sc.info:
            L.box().label(text=sc.info, icon='INFO')


# =============================================================================
# SECTION 11 : 登録
# =============================================================================

CLASSES = (
    VPOLS_Direction,
    VPOLS_Result,
    VPOLS_PlateauLodBand,
    VPOLS_PlateauFile,
    VPOLS_SectionPoint,
    VPOLS_Section,
    VPOLS_Site,
    VPOLS_Props,
    VPOLS_OT_generate,
    VPOLS_OT_generate_all,
    VPOLS_OT_site_add,
    VPOLS_OT_site_remove,
    VPOLS_OT_site_duplicate,
    VPOLS_OT_site_origin_cursor,
    VPOLS_OT_check_landing_area,
    VPOLS_OT_analyze,
    VPOLS_OT_probe,
    VPOLS_OT_select_result,
    VPOLS_OT_extract,
    VPOLS_OT_export_csv,
    VPOLS_OT_export_exchange,
    VPOLS_OT_import_exchange,
    VPOLS_OT_import_vpsite,
    VPOLS_OT_align_city,
    VPOLS_OT_plateau_band_add,
    VPOLS_OT_plateau_band_remove,
    VPOLS_OT_plateau_band_default,
    VPOLS_OT_plateau_api_test,
    VPOLS_OT_plateau_search,
    VPOLS_OT_plateau_import_selected,
    VPOLS_OT_plateau_cancel_job,
    VPOLS_OT_plateau_import_gml,
    VPOLS_OT_plateau_dem_search,
    VPOLS_OT_plateau_dem_import_selected,
    VPOLS_OT_plateau_import_dem_gml,
    VPOLS_OT_plateau_dem_clear,
    VPOLS_OT_plateau_import_mesh,
    VPOLS_OT_plateau_clear,
    VPOLS_OT_section_draw,
    VPOLS_OT_section_draw_clear,
    VPOLS_OT_section_cutaway_apply,
    VPOLS_OT_section_cutaway_restore,
    VPOLS_OT_section_build,
    VPOLS_OT_section_cache,
    VPOLS_OT_section_from_cursor,
    VPOLS_OT_section_at_result,
    VPOLS_OT_section_csv,
    VPOLS_UL_results,
    VPOLS_UL_sites,
    VPOLS_UL_plateau_bands,
    VPOLS_UL_plateau_files,
    VPOLS_PT_exchange,
    VPOLS_PT_datum,
    VPOLS_PT_surface,
    VPOLS_PT_plateau,
    VPOLS_PT_check,
    VPOLS_PT_section,
)


def register():
    # v0.8: PLATEAU版/HTML交換版が同じ Scene.vpols を別スキーマで
    # 上書きしていた問題を解消。登録時は既存PointerPropertyを必ず破棄し、
    # この統合版の VPOLS_Props へ張り直す。
    if hasattr(bpy.types.Scene, "vpols"):
        try:
            del bpy.types.Scene.vpols
        except Exception:
            pass

    for cls in CLASSES:
        bpy.utils.register_class(cls)
    bpy.types.Scene.vpols = PointerProperty(type=VPOLS_Props)

    try:
        p = bpy.context.scene.vpols
        if len(p.plateau_lod_bands) == 0:
            _plateau_default_bands(p)
    except Exception as ex:
        print(f"VP-OLS v0.8.6 initialization warning: {ex}")


def unregister():
    try:
        restore_section_cutaway()
    except Exception:
        pass
    if hasattr(bpy.types.Scene, "vpols"):
        del bpy.types.Scene.vpols
    for cls in reversed(CLASSES):
        try:
            bpy.utils.unregister_class(cls)
        except Exception:
            pass


if __name__ == "__main__":
    register()