# -*- coding: utf-8 -*-
"""
多视角重建项目素材生成脚本 (Blender 4.5, Cycles)
====================================================
生成内容:
  1. 一个带程序化纹理的合成 3D 场景 (桌面静物)
  2. 环绕多视角相机 -> 渲染 RGB 图像 (images/frame_XXXX.png)
  3. 相机内参 + 外参:
       - poses/transforms.json  (OpenCV/COLMAP 约定, 兼容 3DGS / Nerfstudio / COLMAP)
       - sparse/cameras.txt, images.txt, points3D.txt  (COLMAP 文本格式)
  4. 真值稠密点云 gt/dense_pointcloud.ply  +  网格 gt/mesh.obj
  5. scene.blend (可打开复现)  +  metadata.json

坐标约定 (重要):
  - 相机坐标系采用 OpenCV/COLMAP 约定: x 向右, y 向下, z 向前(指向场景)
  - c2w (transform_matrix) 把该相机坐标系变换到 Blender 世界坐标系(z 向上)
  - 内参 K:  fx = W/2 / tan(fov_x/2),  fy = H/2 / tan(fov_y/2),  cx=W/2, cy=H/2
  - 脚本内置"重投影自检": 用 Blender 的 world_to_camera_view 逐点对照,
    若误差 < 2px 则位姿/内参数学正确。

用法:
  blender.exe --background --factory-startup --python generate_dataset.py -- [参数]
  参数: --res N --samples N --rings N --azim N --points N --outdir PATH --smoke
"""

import bpy
import math
import os
import json
import sys
import time
import numpy as np
from mathutils import Vector, Matrix
from bpy_extras.object_utils import world_to_camera_view


# ---------------------------------------------------------------- 参数解析
def parse_args():
    args = sys.argv
    a = {}
    if "--" in args:
        rest = args[args.index("--") + 1:]
        it = iter(rest)
        for x in it:
            if x.startswith("--"):
                key = x[2:]
                try:
                    val = next(it)
                except StopIteration:
                    val = "1"
                a[key] = val
    return a


# ---------------------------------------------------------------- 工具函数
def T(name):
    t0 = time.time()
    if name in _t0:
        print("    [%.2fs] %s" % (t0 - _t0[name], name))
    else:
        print("    [start] %s" % name)
    _t0[name] = time.time()


_t0 = {}


def make_textured_material(name, tex_kind, color=None, scale=(1, 1, 1),
                           extra=None, roughness=0.5):
    """创建一个使用 Generated 坐标 + Mapping 的程序化纹理材质."""
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    nt = m.node_tree
    nt.nodes.clear()
    links = nt.links

    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    tc = nt.nodes.new("ShaderNodeTexCoord")
    mp = nt.nodes.new("ShaderNodeMapping")
    tex = nt.nodes.new(tex_kind)

    out.location = (600, 0)
    bsdf.location = (400, 0)
    tex.location = (200, 0)
    mp.location = (0, 0)
    tc.location = (-200, 0)

    mp.inputs["Scale"].default_value = scale
    links.new(tc.outputs["Generated"], mp.inputs["Vector"])
    if "Vector" in tex.inputs:
        links.new(mp.outputs["Vector"], tex.inputs["Vector"])

    if extra:
        for k, v in extra.items():
            if k in tex.inputs:
                tex.inputs[k].default_value = v

    if color is not None:
        # 若纹理有 Color1/Color2 (checker/brick) 则直接上色
        pass

    links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Roughness"].default_value = roughness
    links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    return m


def checker_material(name, c1, c2, scale=8.0, roughness=0.6):
    m = bpy.data.materials.new(name)
    m.use_nodes = True
    nt = m.node_tree
    nt.nodes.clear()
    links = nt.links
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    tc = nt.nodes.new("ShaderNodeTexCoord")
    mp = nt.nodes.new("ShaderNodeMapping")
    tex = nt.nodes.new("ShaderNodeTexChecker")
    out.location = (600, 0)
    bsdf.location = (400, 0)
    tex.location = (200, 0)
    mp.location = (0, 0)
    tc.location = (-200, 0)
    mp.inputs["Scale"].default_value = (scale, scale, scale)
    links.new(tc.outputs["Generated"], mp.inputs["Vector"])
    links.new(mp.outputs["Vector"], tex.inputs["Vector"])
    tex.inputs["Color1"].default_value = (c1[0], c1[1], c1[2], 1.0)
    tex.inputs["Color2"].default_value = (c2[0], c2[1], c2[2], 1.0)
    links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
    bsdf.inputs["Roughness"].default_value = roughness
    links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    return m


def add_object(name, op, mat, location, color, **opkw):
    """用 bpy.ops 添加图元, 赋材质, 记录真值颜色."""
    getattr(bpy.ops.mesh, op)(**opkw)
    ob = bpy.context.active_object
    ob.name = name
    ob.location = location
    if mat is not None:
        ob.data.materials.append(mat)
    ob["gt_color"] = (color[0], color[1], color[2])
    return ob


def drop_to_ground(objs, ground_z=0.0):
    """把每个网格物体精确放到地面(z=ground_z), 修正非对称图元的陷地/悬空."""
    bpy.context.view_layer.update()
    for obj in objs:
        if obj.type != "MESH":
            continue
        min_z = min((obj.matrix_world @ Vector(c)).z for c in obj.bound_box)
        obj.location.z += (ground_z - min_z)


# ---------------------------------------------------------------- 场景搭建
def setup_scene():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    scene = bpy.context.scene
    scene.render.resolution_x = RES
    scene.render.resolution_y = RES
    scene.render.image_settings.file_format = "PNG"
    scene.render.image_settings.color_mode = "RGB"
    scene.render.image_settings.color_depth = "8"
    scene.render.film_transparent = False

    # 世界背景(天空)
    world = bpy.data.worlds.new("World")
    scene.world = world
    world.use_nodes = True
    bg = world.node_tree.nodes.get("Background")
    bg.inputs["Color"].default_value = (0.45, 0.55, 0.68, 1.0)
    bg.inputs["Strength"].default_value = 0.7

    # 太阳光
    sun_data = bpy.data.lights.new("Sun", "SUN")
    sun_data.energy = 3.2
    sun_data.angle = math.radians(1.0)
    sun = bpy.data.objects.new("Sun", sun_data)
    scene.collection.objects.link(sun)
    sun.rotation_euler = (math.radians(55), 0, math.radians(35))

    # 补光(柔和)
    fill_data = bpy.data.lights.new("Fill", "AREA")
    fill_data.energy = 40
    fill_data.size = 6
    fill = bpy.data.objects.new("Fill", fill_data)
    scene.collection.objects.link(fill)
    fill.location = (4, -2, 5)
    fill.rotation_euler = (math.radians(60), 0, math.radians(-40))

    # 材质
    mat_ground = checker_material("M_Ground", (0.50, 0.50, 0.52), (0.30, 0.30, 0.32),
                                  scale=18.0, roughness=0.7)
    mat_brick = make_textured_material(
        "M_Brick", "ShaderNodeTexBrick", scale=(6, 6, 6),
        extra={"Color1": (0.62, 0.24, 0.18, 1), "Color2": (0.55, 0.20, 0.15, 1),
               "Mortar": (0.82, 0.80, 0.76, 1), "Mortar Size": 0.02,
               "Brick Width": 0.55, "Row Height": 0.22}, roughness=0.5)
    mat_noise = make_textured_material(
        "M_Noise", "ShaderNodeTexNoise", scale=(5, 5, 5),
        extra={"Detail": 6.0, "Roughness": 0.6, "Distortion": 0.0}, roughness=0.45)
    mat_voronoi = make_textured_material(
        "M_Voronoi", "ShaderNodeTexVoronoi", scale=(8, 8, 8),
        extra={"Randomness": 1.0}, roughness=0.4)
    mat_wave = make_textured_material(
        "M_Wave", "ShaderNodeTexWave", scale=(4, 4, 4),
        extra={"Detail": 5.0, "Distortion": 0.6}, roughness=0.55)
    mat_check = checker_material("M_Check", (0.85, 0.30, 0.25), (0.95, 0.95, 0.93),
                                 scale=10.0, roughness=0.4)
    mat_stone = make_textured_material(
        "M_Stone", "ShaderNodeTexNoise", scale=(6, 6, 6),
        extra={"Detail": 8.0, "Roughness": 0.7, "Distortion": 0.3}, roughness=0.5)

    # 地面
    bpy.ops.mesh.primitive_plane_add(size=18)
    ground = bpy.context.active_object
    ground.name = "Ground"
    ground.data.materials.append(mat_ground)
    ground["gt_color"] = (0.5, 0.5, 0.5)

    objs = [ground]
    # 砖块立方体
    add_object("Cube_Brick", "primitive_cube_add", mat_brick,
               location=(-1.35, -0.85, 0.75), color=(0.62, 0.24, 0.18))
    bpy.context.active_object.scale = (1.5, 1.5, 1.5)
    bpy.context.active_object.rotation_euler = (0, 0, math.radians(12))
    objs.append(bpy.context.active_object)

    # 噪声球
    add_object("Sphere_Noise", "primitive_uv_sphere_add", mat_noise,
               location=(1.15, -1.05, 0.72), color=(0.55, 0.55, 0.6))
    bpy.context.active_object.scale = (0.72, 0.72, 0.72)
    objs.append(bpy.context.active_object)

    # 棋盘格圆柱
    add_object("Cylinder_Check", "primitive_cylinder_add", mat_check,
               location=(0.95, 0.95, 0.6), color=(0.85, 0.3, 0.25))
    bpy.context.active_object.scale = (0.45, 0.45, 0.6)
    objs.append(bpy.context.active_object)

    # 波浪圆锥
    add_object("Cone_Wave", "primitive_cone_add", mat_wave,
               location=(-1.25, 0.95, 0.7), color=(0.5, 0.6, 0.5))
    bpy.context.active_object.scale = (0.62, 0.62, 0.7)
    objs.append(bpy.context.active_object)

    # 噪声环面
    add_object("Torus_Noise", "primitive_torus_add", mat_voronoi,
               location=(0.05, -1.35, 0.3), color=(0.4, 0.55, 0.6))
    bpy.context.active_object.scale = (0.9, 0.9, 0.9)
    objs.append(bpy.context.active_object)

    # 石纹猴子
    add_object("Monkey_Stone", "primitive_monkey_add", mat_stone,
               location=(0.05, 0.15, 1.15), color=(0.7, 0.68, 0.62))
    bpy.context.active_object.scale = (1.05, 1.05, 1.05)
    bpy.context.active_object.rotation_euler = (0, 0, math.radians(-15))
    objs.append(bpy.context.active_object)

    # 沃罗诺伊二十面体
    add_object("Ico_Voronoi", "primitive_ico_sphere_add", mat_voronoi,
               location=(1.7, 0.35, 0.55), color=(0.5, 0.4, 0.6))
    bpy.context.active_object.scale = (0.55, 0.55, 0.55)
    objs.append(bpy.context.active_object)

    # 第二个小立方体(噪声)作为前景
    add_object("Cube_Noise", "primitive_cube_add", mat_noise,
               location=(-0.6, 1.55, 0.45), color=(0.6, 0.5, 0.35))
    bpy.context.active_object.scale = (0.9, 0.9, 0.9)
    bpy.context.active_object.rotation_euler = (0, 0, math.radians(25))
    objs.append(bpy.context.active_object)

    # 把所有物体精确落到地面(修正猴子陷地、环面悬空等问题)
    drop_to_ground(objs)

    return scene, objs


# ---------------------------------------------------------------- Cycles GPU
def setup_cycles():
    scene = bpy.context.scene
    scene.render.engine = "CYCLES"
    prefs = bpy.context.preferences.addons["cycles"].preferences
    dtype = "CPU"
    for cand in ("OPTIX", "CUDA"):
        try:
            prefs.compute_device_type = cand
            prefs.get_devices()
            enabled = False
            for d in prefs.devices:
                d.use = (d.type == cand)
                if d.type == cand:
                    enabled = True
            if enabled:
                dtype = cand
                break
        except Exception as e:
            print("    device setup %s failed: %s" % (cand, e))
    scene.cycles.device = "GPU" if dtype != "CPU" else "CPU"
    scene.cycles.samples = SAMPLES
    scene.cycles.use_denoising = True
    if dtype == "OPTIX":
        scene.cycles.denoiser = "OPTIX"
    scene.cycles.use_adaptive_sampling = False
    print("    Cycles device:", dtype, "| samples:", SAMPLES)


# ---------------------------------------------------------------- 相机位姿
def make_camera(scene):
    cam_data = bpy.data.cameras.new("Cam")
    cam = bpy.data.objects.new("Cam", cam_data)
    scene.collection.objects.link(cam)
    cam_data.lens = 40.0
    cam_data.sensor_fit = "HORIZONTAL"
    scene.camera = cam
    return cam


def look_at(location, target):
    loc = Vector(location)
    tgt = Vector(target)
    z = (loc - tgt).normalized()          # 相机 +Z 背向目标 (Blender 相机 -Z 为视线方向)
    x = Vector((0, 0, 1)).cross(z)        # 世界 up × z -> 相机右
    if x.length < 1e-6:
        x = Vector((1, 0, 0))
    x.normalize()
    y = z.cross(x)                         # 相机上
    y.normalize()
    m = Matrix.Identity(4)
    # 旋转矩阵的"列"是相机基向量在世界系下的坐标
    m[0][0], m[1][0], m[2][0] = x.x, x.y, x.z   # 列0 = x(右)
    m[0][1], m[1][1], m[2][1] = y.x, y.y, y.z   # 列1 = y(上)
    m[0][2], m[1][2], m[2][2] = z.x, z.y, z.z   # 列2 = z(后)
    m[0][3], m[1][3], m[2][3] = loc.x, loc.y, loc.z
    return m


def build_poses(target, radius=6.0):
    poses = []
    for ri, elev in enumerate(ELEVATIONS):
        for k in range(AZIM):
            theta = 2 * math.pi * k / AZIM + ri * (math.pi / AZIM)
            x = target[0] + radius * math.cos(elev) * math.cos(theta)
            y = target[1] + radius * math.cos(elev) * math.sin(theta)
            z = target[2] + radius * math.sin(elev)
            loc = Vector((x, y, z))
            m = look_at(loc, Vector(target))
            poses.append((m, loc))
    return poses


# ---------------------------------------------------------------- 内参/外参
def camera_intrinsics(cam):
    # 方形图像 + 方形像素(pixel_aspect=1) -> fx == fy
    angle_x = cam.data.angle_x
    fx = 0.5 * RES / math.tan(0.5 * angle_x)
    fy = fx
    cx = 0.5 * RES
    cy = 0.5 * RES
    angle_y = angle_x  # 方形图像下垂直FOV == 水平FOV
    K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64)
    return K, fx, fy, cx, cy, angle_x, angle_y


def c2w_opencv(m):
    """matrix_world(Blender, OpenGL相机) -> c2w(OpenCV相机, x右 y下 z前)."""
    F = np.diag([1.0, -1.0, -1.0, 1.0])
    return np.asarray(m, dtype=np.float64) @ F


# ---------------------------------------------------------------- 重投影自检
def self_check(cam, scene, world_points):
    """用 Blender 的 world_to_camera_view 对照我们的 c2w+K, 返回最大像素误差."""
    K, *_ = camera_intrinsics(cam)
    m = cam.matrix_world
    c2w = c2w_opencv(m)
    w2c = np.linalg.inv(c2w)
    max_err = 0.0
    n_in = 0
    worst = None
    for p in world_points:
        bv = world_to_camera_view(scene, cam, Vector(p))
        # world_to_camera_view 的 y 以左下角为原点(OpenGL), 像素坐标以左上角为原点
        bu, bv2, bd = bv[0] * RES, (1.0 - bv[1]) * RES, bv[2]
        if bd <= 0:
            continue
        xc = w2c @ np.array([p[0], p[1], p[2], 1.0])
        if xc[2] <= 0:
            continue
        u = K[0, 0] * xc[0] / xc[2] + K[0, 2]
        v = K[1, 1] * xc[1] / xc[2] + K[1, 2]
        # 只统计投影落在画面附近(可见)的点, 排除极端离轴投影的浮点噪声
        if not (-0.5 * RES <= u <= 1.5 * RES and -0.5 * RES <= v <= 1.5 * RES):
            continue
        err = math.hypot(u - bu, v - bv2)
        if worst is None or err > worst[0]:
            worst = (err, p, (u, v), (bu, bv2))
        max_err = max(max_err, err)
        n_in += 1
    if worst and worst[0] > 2.0:
        print("    最差点: p=%s mine=%s blender=%s err=%.2f" %
              (worst[1], worst[2], worst[3], worst[0]), flush=True)
    return max_err, n_in


# ---------------------------------------------------------------- 真值采样
def collect_world_triangles(objs, depsgraph):
    tris = []  # (a,b,c,normal,color)
    for obj in objs:
        if obj.type != "MESH":
            continue
        color = obj.get("gt_color", (0.8, 0.8, 0.8))
        o = obj.evaluated_get(depsgraph)
        me = o.to_mesh()
        me.transform(obj.matrix_world)
        me.calc_loop_triangles()
        loops = me.loops
        verts = me.vertices
        for lt in me.loop_triangles:
            i0, i1, i2 = lt.loops
            v0 = verts[loops[i0].vertex_index].co
            v1 = verts[loops[i1].vertex_index].co
            v2 = verts[loops[i2].vertex_index].co
            a = np.array(v0); b = np.array(v1); c = np.array(v2)
            n = np.cross(b - a, c - a)
            nlen = np.linalg.norm(n)
            if nlen < 1e-12:
                continue
            n = n / nlen
            tris.append((a, b, c, n, color))
    return tris


def sample_points(tris, n_points, seed=42):
    rng = np.random.default_rng(seed)
    areas = np.array([0.5 * np.linalg.norm(np.cross(b - a, c - a)) for a, b, c, n, col in tris])
    total = areas.sum()
    if total <= 0:
        return np.zeros((0, 3)), np.zeros((0, 3)), np.zeros((0, 3))
    counts = np.floor(n_points * areas / total).astype(np.int64)
    diff = n_points - counts.sum()
    if diff > 0:
        idx = rng.choice(len(tris), size=int(diff), p=areas / total)
        for i in idx:
            counts[i] += 1
    P, N, C = [], [], []
    for i, (a, b, c, n, col) in enumerate(tris):
        k = counts[i]
        if k <= 0:
            continue
        r1 = rng.random(k); r2 = rng.random(k)
        sq = np.sqrt(r1)
        u = 1.0 - sq
        v = sq * (1.0 - r2)
        w = 1.0 - u - v
        pts = a[None, :] * u[:, None] + b[None, :] * v[:, None] + c[None, :] * w[:, None]
        P.append(pts)
        N.append(np.tile(n, (k, 1)))
        C.append(np.tile(np.array(col), (k, 1)))
    P = np.concatenate(P) if P else np.zeros((0, 3))
    N = np.concatenate(N) if N else np.zeros((0, 3))
    C = np.concatenate(C) if C else np.zeros((0, 3))
    return P, N, C


def write_ply(path, P, N, C):
    with open(path, "w") as f:
        f.write("ply\nformat ascii 1.0\n")
        f.write("element vertex %d\n" % len(P))
        f.write("property float x\nproperty float y\nproperty float z\n")
        f.write("property float nx\nproperty float ny\nproperty float nz\n")
        f.write("property uchar red\nproperty uchar green\nproperty uchar blue\n")
        f.write("end_header\n")
        for i in range(len(P)):
            r = int(min(255, max(0, round(C[i, 0] * 255))))
            g = int(min(255, max(0, round(C[i, 1] * 255))))
            b = int(min(255, max(0, round(C[i, 2] * 255))))
            f.write("%.6f %.6f %.6f %.6f %.6f %.6f %d %d %d\n" %
                    (P[i, 0], P[i, 1], P[i, 2], N[i, 0], N[i, 1], N[i, 2], r, g, b))


# ---------------------------------------------------------------- 导出
def quat_from_matrix(R):
    m = Matrix(((R[0, 0], R[0, 1], R[0, 2]),
                (R[1, 0], R[1, 1], R[1, 2]),
                (R[2, 0], R[2, 1], R[2, 2])))
    q = m.to_quaternion()  # (w,x,y,z)
    return q.w, q.x, q.y, q.z


def export_poses(cam, poses, K, fx, fy, cx, cy, angle_x, angle_y):
    frames = []
    colmap_images = []
    for i, (m, loc) in enumerate(poses):
        c2w = c2w_opencv(m)
        frames.append({
            "file_path": "images/frame_%04d.png" % i,
            "transform_matrix": c2w.tolist(),
        })
        # COLMAP: world->camera (OpenCV)
        w2c = np.linalg.inv(c2w)
        R = w2c[:3, :3]
        t = w2c[:3, 3]
        qw, qx, qy, qz = quat_from_matrix(R)
        colmap_images.append((i, qw, qx, qy, qz, t[0], t[1], t[2]))

    transform = {
        "camera_model": "OPENCV",
        "camera_angle_x": angle_x,
        "camera_angle_y": angle_y,
        "w": RES, "h": RES,
        "fl_x": fx, "fl_y": fy, "cx": cx, "cy": cy,
        "k1": 0.0, "k2": 0.0, "p1": 0.0, "p2": 0.0,
        "frames": frames,
    }
    with open(os.path.join(OUTDIR, "poses", "transforms.json"), "w") as f:
        json.dump(transform, f, indent=2)

    # COLMAP 文本
    with open(os.path.join(OUTDIR, "sparse", "cameras.txt"), "w") as f:
        f.write("# Camera list with one line per camera\n")
        f.write("# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n")
        f.write("1 PINHOLE %d %d %.6f %.6f %.6f %.6f\n" % (RES, RES, fx, fy, cx, cy))
    with open(os.path.join(OUTDIR, "sparse", "images.txt"), "w") as f:
        f.write("# Image list with two lines per image\n")
        f.write("# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n")
        for (i, qw, qx, qy, qz, tx, ty, tz) in colmap_images:
            f.write("%d %.9f %.9f %.9f %.9f %.9f %.9f %.9f 1 frame_%04d.png\n" %
                    (i + 1, qw, qx, qy, qz, tx, ty, tz, i))
            f.write("\n")
    with open(os.path.join(OUTDIR, "sparse", "points3D.txt"), "w") as f:
        f.write("# 3D point list with one line per point\n")
        f.write("# (空: 稠密真值点云见 gt/dense_pointcloud.ply)\n")


def write_mesh_ply(path, tris):
    """把(已三角化、世界坐标)的三角形写成索引 PLY 网格."""
    verts = []
    faces = []
    vmap = {}
    for (a, b, c, n, col) in tris:
        fi = []
        for p in (a, b, c):
            key = (round(float(p[0]), 5), round(float(p[1]), 5), round(float(p[2]), 5))
            if key not in vmap:
                vmap[key] = len(verts)
                verts.append(p)
            fi.append(vmap[key])
        faces.append(fi)
    with open(path, "w") as f:
        f.write("ply\nformat ascii 1.0\n")
        f.write("element vertex %d\n" % len(verts))
        f.write("property float x\nproperty float y\nproperty float z\n")
        f.write("element face %d\n" % len(faces))
        f.write("property list uchar int vertex_indices\n")
        f.write("end_header\n")
        for p in verts:
            f.write("%.6f %.6f %.6f\n" % (p[0], p[1], p[2]))
        for fi in faces:
            f.write("3 %d %d %d\n" % (fi[0], fi[1], fi[2]))


# ---------------------------------------------------------------- 渲染
def render_all(scene, cam, poses):
    img_dir = os.path.join(OUTDIR, "images")
    os.makedirs(img_dir, exist_ok=True)
    for i, (m, loc) in enumerate(poses):
        cam.matrix_world = m
        scene.render.filepath = os.path.join(img_dir, "frame_%04d.png" % i)
        bpy.ops.render.render(write_still=True)
        if (i + 1) % 10 == 0 or i == 0:
            print("    rendered %d/%d" % (i + 1, len(poses)), flush=True)


# ---------------------------------------------------------------- main
def main():
    global RES, SAMPLES, AZIM, ELEVATIONS, POINTS, OUTDIR, SMOKE
    a = parse_args()
    RES = int(a.get("res", 800))
    SAMPLES = int(a.get("samples", 128))
    RINGS = int(a.get("rings", 3))
    AZIM = int(a.get("azim", 24))
    POINTS = int(a.get("points", 300000))
    OUTDIR = os.path.abspath(a.get("outdir", os.path.dirname(os.path.abspath(__file__))))
    SMOKE = ("smoke" in a)

    if SMOKE:
        RINGS, AZIM, RES, SAMPLES, POINTS = 1, 3, 400, 32, 5000

    # 三个仰角环: 约 0° / 15° / 30° (弧度)
    ELEVATIONS = [math.radians(e) for e in (4.0, 16.0, 30.0)][:RINGS]

    for sub in ("images", "poses", "sparse", "gt"):
        os.makedirs(os.path.join(OUTDIR, sub), exist_ok=True)

    print("== 搭建场景 ==", flush=True)
    scene, objs = setup_scene()

    print("== 相机 ==", flush=True)
    cam = make_camera(scene)

    print("== 渲染引擎 ==", flush=True)
    setup_cycles()

    print("== 位姿 (%d 视角) ==" % (RINGS * AZIM), flush=True)
    target = (0.0, 0.0, 0.55)
    poses = build_poses(target, radius=6.2)

    K, fx, fy, cx, cy, angle_x, angle_y = camera_intrinsics(cam)
    print("    内参: fx=%.2f fy=%.2f cx=%.2f cy=%.2f  fov_x=%.3f fov_y=%.3f" %
          (fx, fy, cx, cy, angle_x, angle_y), flush=True)

    print("== 真值采样(自检用) ==", flush=True)
    depsgraph = bpy.context.evaluated_depsgraph_get()
    tris = collect_world_triangles(objs, depsgraph)
    print("    三角形数:", len(tris), flush=True)
    check_pts, _, _ = sample_points(tris, 2000, seed=1)

    print("== 重投影自检 ==", flush=True)
    cam.matrix_world = poses[0][0]
    max_err, n_in = self_check(cam, scene, check_pts)
    print("    对照 Blender world_to_camera_view: 参与点 %d, 最大像素误差 %.3f px" %
          (n_in, max_err), flush=True)
    if max_err > 2.0:
        print("    [警告] 自检误差偏大, 位姿/内参可能有问题!", flush=True)

    print("== 渲染 %d 张 ==" % len(poses), flush=True)
    t0 = time.time()
    render_all(scene, cam, poses)
    print("    渲染总耗时 %.1f s" % (time.time() - t0), flush=True)

    print("== 导出位姿 ==", flush=True)
    export_poses(cam, poses, K, fx, fy, cx, cy, angle_x, angle_y)

    print("== 导出真值点云 (%d 点) ==" % POINTS, flush=True)
    P, N, C = sample_points(tris, POINTS, seed=42)
    write_ply(os.path.join(OUTDIR, "gt", "dense_pointcloud.ply"), P, N, C)
    print("    点数:", len(P), flush=True)

    print("== 导出网格 ==", flush=True)
    write_mesh_ply(os.path.join(OUTDIR, "gt", "mesh.ply"), tris)

    print("== 保存 scene.blend ==", flush=True)
    bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "scene.blend"))

    meta = {
        "resolution": [RES, RES], "samples": SAMPLES,
        "n_views": len(poses), "rings": RINGS, "azimuth_per_ring": AZIM,
        "camera_lens_mm": 40.0, "radius": 6.2, "target": list(target),
        "intrinsics": {"fx": fx, "fy": fy, "cx": cx, "cy": cy,
                       "angle_x": angle_x, "angle_y": angle_y},
        "convention": "OpenCV/COLMAP (x right, y down, z forward)",
        "gt_points": int(len(P)),
    }
    with open(os.path.join(OUTDIR, "metadata.json"), "w") as f:
        json.dump(meta, f, indent=2)

    print("== 完成 ==", flush=True)
    print(json.dumps(meta, indent=2), flush=True)


if __name__ == "__main__":
    main()
