"""降りてゆく家 — 斜面を五つの段で降りる家。設計: plan.md
使い方:
  blender -b -P house.py -- <出力dir> <サンプル数> [幅 高さ] [画の名前...]
座標: x 東西（幅）、y 斜面に沿う（+y が上=道路側、家は -y へ降りる）、z 上。原点は段0の床の中心。
"""
import bpy, bmesh, sys, os, math, time
import mathutils
import numpy as np

argv = sys.argv[sys.argv.index("--") + 1:]
OUT = argv[0]; SAMPLES = int(argv[1])
RES = (int(argv[2]), int(argv[3])) if len(argv) >= 4 else (1200, 750)
VIEWS = argv[4:]
os.makedirs(OUT, exist_ok=True)
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete()
for m in list(bpy.data.materials): bpy.data.materials.remove(m)
scene = bpy.context.scene
rng = np.random.default_rng(11)

# ============ 寸法 ============
W = 6.0            # 外壁の外々の幅
WI = 5.4           # 内法
T = 0.3            # 壁厚（柱 150 + 断熱 + 仕上げ）
DROP = 1.5         # 一段の下がり
STAIR = 1.5        # 階段の奥行（蹴上 0.3 × 5 段）
# 部屋: 奥行、天井高（北端の梁下）、名前
ROOMS = [
    (5.0, 2.3, "entry"),
    (5.5, 2.6, "dining"),
    (6.0, 4.2, "living"),
    (5.0, 2.3, "bed"),
    (5.5, 2.7, "water"),
]
# 各段の y 範囲と床 z
LV = []
y = 0.0
for k, (d, h, nm) in enumerate(ROOMS):
    LV.append(dict(y0=y, y1=y - d, z=-DROP * k, h=h, name=nm))
    y -= d + STAIR
Y_END = y + STAIR          # 最下段の南端
ROOF_T = 0.22              # 屋根の厚み
PITCH = 0.05               # 屋根の勾配（南へ下がる）
BAND = 1.2                 # 光の段（高窓）の高さ
EAVE = 0.9                 # 軒の出

Y_STREAM = Y_END - 7.5     # 谷川の中心
def ground_z(x, y):
    """地形。家の下は一様な斜面、北の道路は平ら。家の南で斜面はゆるみ、谷川（V字の窪み）を越えると対岸が登る"""
    if y > 2.0: return 0.4 + 0.231 * 2.0
    if y >= Y_END - 3: return 0.4 + 0.231 * y
    zb = 0.4 + 0.231 * (Y_END - 3)
    yv = Y_END - 3 - y                       # 家の南端から南へ
    dv = abs(y - Y_STREAM)
    if y >= Y_STREAM: z = zb - 0.12 * yv
    else: z = zb - 0.12 * (Y_END - 3 - Y_STREAM) + 0.22 * (Y_STREAM - y)   # 対岸は登る
    z -= 1.1 * max(0.0, 1 - dv / 3.5) ** 1.5   # 川床
    return z
Z_WATER = None
POND = dict(x0=-5.0, x1=5.0, y0=Y_STREAM + 0.6, y1=LV[4]["y1"] - 0.05)    # y0 が南（谷川側）、北端は基壇の壁に接する
POND["z"] = ground_z(0, Y_END - 4.5) - 0.25
def in_pond(x, y, m=0.0): return POND["x0"] - m < x < POND["x1"] + m and POND["y0"] - m < y < POND["y1"] + m

# ============ 材質 ============
def principled(m): return m.node_tree.nodes.get("Principled BSDF")
def new_mat(name, base, rough=0.6, **kw):
    m = bpy.data.materials.new(name); m.use_nodes = True; b = principled(m)
    b.inputs["Base Color"].default_value = (*base, 1); b.inputs["Roughness"].default_value = rough
    for k, v in kw.items():
        if k in b.inputs: b.inputs[k].default_value = v
    return m

def noisy_mat(name, c1, c2, scale=8.0, rough=0.7, bump=0.15, stretch=None, detail=8.0):
    m = bpy.data.materials.new(name); m.use_nodes = True; nt = m.node_tree; b = principled(m)
    b.inputs["Roughness"].default_value = rough
    tex = nt.nodes.new("ShaderNodeTexNoise"); tex.inputs["Scale"].default_value = scale; tex.inputs["Detail"].default_value = detail
    co = nt.nodes.new("ShaderNodeTexCoord"); mp = nt.nodes.new("ShaderNodeMapping")
    mp.inputs["Scale"].default_value = stretch or (1, 1, 1)
    nt.links.new(co.outputs["Object"], mp.inputs["Vector"]); nt.links.new(mp.outputs["Vector"], tex.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.35; ramp.color_ramp.elements[0].color = (*c1, 1)
    ramp.color_ramp.elements[1].position = 0.65; ramp.color_ramp.elements[1].color = (*c2, 1)
    nt.links.new(tex.outputs["Fac"], ramp.inputs["Fac"]); nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"])
    bmp = nt.nodes.new("ShaderNodeBump"); bmp.inputs["Strength"].default_value = bump
    nt.links.new(tex.outputs["Fac"], bmp.inputs["Height"]); nt.links.new(bmp.outputs["Normal"], b.inputs["Normal"])
    return m

def board_mat(name, c1, c2, width=0.14, along='x', rough=0.5, groove=0.35, joint=0.025):
    """板張り: 板ごとに色が違い、継ぎ目に溝。along は板の長手方向"""
    m = bpy.data.materials.new(name); m.use_nodes = True; nt = m.node_tree; b = principled(m)
    b.inputs["Roughness"].default_value = rough
    co = nt.nodes.new("ShaderNodeTexCoord"); sep = nt.nodes.new("ShaderNodeSeparateXYZ")
    nt.links.new(co.outputs["Object"], sep.inputs["Vector"])
    ax = {'x': ("Y", "X"), 'y': ("X", "Y"), 'z': ("X", "Z"), 'course': ("Z", "X")}[along]   # course: 石積みの横目地（z に積む）
    across, lengthwise = sep.outputs[ax[0]], sep.outputs[ax[1]]
    div = nt.nodes.new("ShaderNodeMath"); div.operation = 'DIVIDE'; div.inputs[1].default_value = width; nt.links.new(across, div.inputs[0])
    fl = nt.nodes.new("ShaderNodeMath"); fl.operation = 'FLOOR'; nt.links.new(div.outputs[0], fl.inputs[0])
    fr = nt.nodes.new("ShaderNodeMath"); fr.operation = 'FRACT'; nt.links.new(div.outputs[0], fr.inputs[0])
    wn = nt.nodes.new("ShaderNodeTexWhiteNoise"); wn.noise_dimensions = '1D'; nt.links.new(fl.outputs[0], wn.inputs["W"])
    mp = nt.nodes.new("ShaderNodeMapping"); sc = [3, 3, 3]; sc[{'x': 0, 'y': 1, 'z': 2, 'course': 0}[along]] = 0.25; mp.inputs["Scale"].default_value = sc
    nt.links.new(co.outputs["Object"], mp.inputs["Vector"])
    nz = nt.nodes.new("ShaderNodeTexNoise"); nz.inputs["Scale"].default_value = 6.0; nz.inputs["Detail"].default_value = 7
    nt.links.new(mp.outputs["Vector"], nz.inputs["Vector"])
    mix = nt.nodes.new("ShaderNodeMath"); mix.operation = 'MULTIPLY_ADD'
    nt.links.new(wn.outputs["Value"], mix.inputs[0]); mix.inputs[1].default_value = 0.5; nt.links.new(nz.outputs["Fac"], mix.inputs[2])
    ramp = nt.nodes.new("ShaderNodeValToRGB")
    ramp.color_ramp.elements[0].position = 0.25; ramp.color_ramp.elements[0].color = (*c1, 1)
    ramp.color_ramp.elements[1].position = 1.0; ramp.color_ramp.elements[1].color = (*c2, 1)
    nt.links.new(mix.outputs[0], ramp.inputs["Fac"])
    g1 = nt.nodes.new("ShaderNodeMath"); g1.operation = 'PINGPONG'; g1.inputs[1].default_value = 0.5; nt.links.new(fr.outputs[0], g1.inputs[0])
    g2 = nt.nodes.new("ShaderNodeMath"); g2.operation = 'GREATER_THAN'; g2.inputs[1].default_value = joint; nt.links.new(g1.outputs[0], g2.inputs[0])
    gro = nt.nodes.new("ShaderNodeMix"); gro.data_type = 'RGBA'
    nt.links.new(g2.outputs[0], gro.inputs["Factor"])
    gro.inputs[6].default_value = (*[c * groove for c in c1], 1)
    nt.links.new(ramp.outputs["Color"], gro.inputs[7])
    nt.links.new(gro.outputs[2], b.inputs["Base Color"])
    bmp = nt.nodes.new("ShaderNodeBump"); bmp.inputs["Strength"].default_value = 0.25
    nt.links.new(g2.outputs[0], bmp.inputs["Height"]); nt.links.new(bmp.outputs["Normal"], b.inputs["Normal"])
    return m

def glass_mat(name):
    m = bpy.data.materials.new(name); m.use_nodes = True; b = principled(m)
    b.inputs["Base Color"].default_value = (0.92, 0.95, 0.96, 1); b.inputs["Roughness"].default_value = 0.03
    b.inputs["Transmission Weight"].default_value = 1.0; b.inputs["IOR"].default_value = 1.5
    return m

def emit_mat(name, color, strength):
    m = bpy.data.materials.new(name); m.use_nodes = True; nt = m.node_tree
    for n in list(nt.nodes): nt.nodes.remove(n)
    e = nt.nodes.new("ShaderNodeEmission"); e.inputs["Color"].default_value = (*color, 1); e.inputs["Strength"].default_value = strength
    o = nt.nodes.new("ShaderNodeOutputMaterial"); nt.links.new(e.outputs[0], o.inputs[0]); return m

def water_mat():
    m = bpy.data.materials.new("water"); m.use_nodes = True; nt = m.node_tree; b = principled(m)
    b.inputs["Base Color"].default_value = (0.35, 0.42, 0.40, 1); b.inputs["Roughness"].default_value = 0.02
    b.inputs["Transmission Weight"].default_value = 0.9; b.inputs["IOR"].default_value = 1.33
    co = nt.nodes.new("ShaderNodeTexCoord"); nz = nt.nodes.new("ShaderNodeTexNoise")
    nz.inputs["Scale"].default_value = 2.5; nz.inputs["Detail"].default_value = 6; nz.inputs["Distortion"].default_value = 0.6
    mp = nt.nodes.new("ShaderNodeMapping"); mp.inputs["Scale"].default_value = (1, 3, 1)
    nt.links.new(co.outputs["Object"], mp.inputs["Vector"]); nt.links.new(mp.outputs["Vector"], nz.inputs["Vector"])
    bmp = nt.nodes.new("ShaderNodeBump"); bmp.inputs["Strength"].default_value = 0.12; bmp.inputs["Distance"].default_value = 0.05
    nt.links.new(nz.outputs["Fac"], bmp.inputs["Height"]); nt.links.new(bmp.outputs["Normal"], b.inputs["Normal"])
    return m

M_PLASTER = noisy_mat("plaster", (0.78, 0.75, 0.69), (0.86, 0.83, 0.77), 7, 0.92, 0.08)
M_PLASTER_D = noisy_mat("plaster_dim", (0.62, 0.58, 0.52), (0.70, 0.66, 0.60), 7, 0.92, 0.08)
M_CEDAR = board_mat("cedar_floor", (0.52, 0.38, 0.24), (0.68, 0.52, 0.34), 0.13, 'y', 0.42)
M_CEIL = board_mat("ceiling", (0.44, 0.32, 0.20), (0.56, 0.42, 0.27), 0.11, 'x', 0.6, 0.5)
M_YAKISUGI = board_mat("yakisugi", (0.030, 0.024, 0.020), (0.075, 0.060, 0.050), 0.15, 'z', 0.6, 0.3)
M_POST = noisy_mat("post", (0.50, 0.37, 0.23), (0.60, 0.46, 0.30), 3, 0.45, 0.08, (1, 1, 12))
M_BEAM = noisy_mat("beam", (0.42, 0.30, 0.18), (0.52, 0.38, 0.24), 3, 0.45, 0.08, (12, 1, 1))
M_OAK = noisy_mat("oak", (0.46, 0.34, 0.20), (0.58, 0.44, 0.27), 3, 0.35, 0.06, (12, 1, 1))
M_ROOF = noisy_mat("roof_metal", (0.035, 0.035, 0.038), (0.06, 0.06, 0.065), 20, 0.35, 0.03, (1, 60, 1), 2)
M_IRON = new_mat("iron", (0.03, 0.03, 0.032), 0.4, Metallic=0.7)
M_STONE = noisy_mat("stone", (0.17, 0.17, 0.165), (0.26, 0.25, 0.24), 5, 0.75, 0.35)
M_MASONRY = board_mat("masonry", (0.09, 0.085, 0.075), (0.40, 0.37, 0.33), 0.2, 'course', 0.85, 0.15, joint=0.09)
M_SLATE = board_mat("slate", (0.13, 0.13, 0.13), (0.22, 0.21, 0.20), 0.6, 'x', 0.3, 0.5)
M_GRAVEL = noisy_mat("gravel", (0.30, 0.28, 0.25), (0.42, 0.40, 0.36), 60, 0.95, 0.6, None, 10)
M_EARTH = noisy_mat("earth", (0.16, 0.13, 0.09), (0.24, 0.20, 0.14), 4, 1.0, 0.5, None, 9)
M_GRASS = noisy_mat("grass", (0.14, 0.17, 0.07), (0.24, 0.27, 0.12), 30, 1.0, 0.6, None, 10)
def ground_mat():
    m = bpy.data.materials.new("ground"); m.use_nodes = True; nt = m.node_tree; b = principled(m); b.inputs["Roughness"].default_value = 1.0
    co = nt.nodes.new("ShaderNodeTexCoord")
    big = nt.nodes.new("ShaderNodeTexNoise"); big.inputs["Scale"].default_value = 0.12; big.inputs["Detail"].default_value = 4
    fine = nt.nodes.new("ShaderNodeTexNoise"); fine.inputs["Scale"].default_value = 6.0; fine.inputs["Detail"].default_value = 9
    nt.links.new(co.outputs["Object"], big.inputs["Vector"]); nt.links.new(co.outputs["Object"], fine.inputs["Vector"])
    r1 = nt.nodes.new("ShaderNodeValToRGB"); e = r1.color_ramp.elements
    e[0].position = 0.35; e[0].color = (0.12, 0.15, 0.05, 1); e[1].position = 0.65; e[1].color = (0.20, 0.15, 0.08, 1)
    e2 = r1.color_ramp.elements.new(0.5); e2.color = (0.19, 0.21, 0.09, 1)
    nt.links.new(big.outputs["Fac"], r1.inputs["Fac"])
    mix = nt.nodes.new("ShaderNodeMix"); mix.data_type = 'RGBA'; mix.blend_type = 'MULTIPLY'; mix.inputs["Factor"].default_value = 0.5
    nt.links.new(r1.outputs["Color"], mix.inputs[6]); 
    r2 = nt.nodes.new("ShaderNodeValToRGB"); r2.color_ramp.elements[0].color = (0.6, 0.6, 0.55, 1); r2.color_ramp.elements[1].color = (1.1, 1.1, 1.05, 1)
    nt.links.new(fine.outputs["Fac"], r2.inputs["Fac"]); nt.links.new(r2.outputs["Color"], mix.inputs[7])
    nt.links.new(mix.outputs[2], b.inputs["Base Color"])
    bmp = nt.nodes.new("ShaderNodeBump"); bmp.inputs["Strength"].default_value = 0.5; nt.links.new(fine.outputs["Fac"], bmp.inputs["Height"]); nt.links.new(bmp.outputs["Normal"], b.inputs["Normal"])
    return m
M_GROUND = ground_mat()
M_FAR = noisy_mat("far_hills", (0.025, 0.045, 0.028), (0.06, 0.085, 0.045), 0.25, 1.0, 2.5, None, 9)
M_MOSS = noisy_mat("moss", (0.10, 0.16, 0.06), (0.17, 0.25, 0.10), 40, 1.0, 0.5, None, 9)
M_BARK = noisy_mat("bark", (0.14, 0.10, 0.07), (0.22, 0.17, 0.12), 6, 0.95, 0.5, (1, 1, 6))
M_CEDAR_LEAF = noisy_mat("cedar_leaf", (0.04, 0.085, 0.04), (0.10, 0.16, 0.07), 18, 0.9, 0.6, None, 7)
M_BROAD_LEAF = noisy_mat("broad_leaf", (0.13, 0.14, 0.04), (0.36, 0.22, 0.06), 20, 0.85, 0.4, None, 7)
M_BROAD_LEAF2 = noisy_mat("broad_leaf2", (0.09, 0.14, 0.04), (0.20, 0.24, 0.07), 20, 0.85, 0.4, None, 7)
M_GRASS_TUFT = noisy_mat("grass_tuft", (0.07, 0.10, 0.035), (0.15, 0.18, 0.06), 30, 0.95, 0.3, None, 6)
M_FERN = new_mat("fern", (0.08, 0.15, 0.05), 0.8)
def leaf_mat(name, c1, c2, c3):
    """葉: 場所によって色が変わり、裏から光を受けると透ける"""
    m = bpy.data.materials.new(name); m.use_nodes = True; nt = m.node_tree
    for n in list(nt.nodes): nt.nodes.remove(n)
    co = nt.nodes.new("ShaderNodeTexCoord"); nz = nt.nodes.new("ShaderNodeTexNoise"); nz.inputs["Scale"].default_value = 0.35; nz.inputs["Detail"].default_value = 3
    nt.links.new(co.outputs["Object"], nz.inputs["Vector"])
    ramp = nt.nodes.new("ShaderNodeValToRGB"); e = ramp.color_ramp.elements
    e[0].position = 0.3; e[0].color = (*c1, 1); e[1].position = 0.7; e[1].color = (*c2, 1); e2 = ramp.color_ramp.elements.new(0.5); e2.color = (*c3, 1)
    nt.links.new(nz.outputs["Fac"], ramp.inputs["Fac"])
    d = nt.nodes.new("ShaderNodeBsdfDiffuse"); t = nt.nodes.new("ShaderNodeBsdfTranslucent")
    nt.links.new(ramp.outputs["Color"], d.inputs["Color"]); nt.links.new(ramp.outputs["Color"], t.inputs["Color"])
    mx = nt.nodes.new("ShaderNodeMixShader"); mx.inputs["Fac"].default_value = 0.3
    o = nt.nodes.new("ShaderNodeOutputMaterial"); nt.links.new(d.outputs[0], mx.inputs[1]); nt.links.new(t.outputs[0], mx.inputs[2]); nt.links.new(mx.outputs[0], o.inputs[0])
    return m
M_LEAF_A = leaf_mat("leaf_a", (0.10, 0.16, 0.04), (0.32, 0.26, 0.06), (0.20, 0.22, 0.05))
M_LEAF_B = leaf_mat("leaf_b", (0.07, 0.13, 0.04), (0.16, 0.22, 0.06), (0.11, 0.17, 0.05))
M_LEAF_C = leaf_mat("leaf_c", (0.30, 0.18, 0.05), (0.52, 0.26, 0.06), (0.40, 0.22, 0.05))
M_FERN.use_backface_culling = False
def poche(m, col=(0.015, 0.013, 0.012)):
    """閉じた立体の材質に: 裏面（断面で切ると見える内側）を黒く塗る"""
    nt = m.node_tree; out = next(n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL')
    src = out.inputs[0].links[0].from_socket
    g = nt.nodes.new("ShaderNodeNewGeometry"); e = nt.nodes.new("ShaderNodeEmission"); e.inputs["Color"].default_value = (*col, 1); e.inputs["Strength"].default_value = 1.0
    mx = nt.nodes.new("ShaderNodeMixShader"); nt.links.new(g.outputs["Backfacing"], mx.inputs[0]); nt.links.new(src, mx.inputs[1]); nt.links.new(e.outputs[0], mx.inputs[2])
    nt.links.new(mx.outputs[0], out.inputs[0])
for _m in (M_PLASTER, M_PLASTER_D, M_CEDAR, M_CEIL, M_YAKISUGI, M_POST, M_BEAM, M_OAK, M_ROOF, M_STONE, M_SLATE, M_GRAVEL, M_EARTH): poche(_m)
poche(M_GROUND, (0.09, 0.075, 0.06))
M_GLASS = glass_mat("glass")
M_WATER = water_mat()
M_POND = water_mat(); M_POND.name = "pond_water"
_b = principled(M_POND); _b.inputs["Base Color"].default_value = (0.06, 0.08, 0.08, 1); _b.inputs["Transmission Weight"].default_value = 0.3
for _n in M_POND.node_tree.nodes:
    if _n.type == 'BUMP': _n.inputs["Strength"].default_value = 0.04
M_FABRIC = noisy_mat("fabric", (0.30, 0.28, 0.24), (0.36, 0.34, 0.30), 40, 1.0, 0.3)
M_LINEN = noisy_mat("linen", (0.80, 0.76, 0.68), (0.86, 0.82, 0.74), 50, 1.0, 0.25)
M_BLACK = new_mat("black", (0.02, 0.02, 0.02), 0.5)
M_LAMP = emit_mat("lamp", (1.0, 0.66, 0.36), 0.9)
M_EMBER = emit_mat("ember", (1.0, 0.40, 0.10), 30.0)
M_PAPER = new_mat("paper", (0.92, 0.90, 0.84), 0.9)
BOOKS = [new_mat(f"book{i}", c, 0.75) for i, c in enumerate([(0.35, 0.08, 0.06), (0.08, 0.16, 0.30), (0.62, 0.55, 0.40), (0.12, 0.22, 0.12), (0.70, 0.66, 0.58), (0.22, 0.12, 0.20), (0.50, 0.30, 0.10), (0.15, 0.15, 0.15), (0.58, 0.20, 0.10), (0.30, 0.38, 0.45)])]

# ============ 部品 ============
def _obj(name, bm, mat, loc=(0, 0, 0)):
    """bmesh → オブジェクト。bpy.ops を使わない（ops は毎回、場面全体を再評価して遅い）"""
    me = bpy.data.meshes.new(name); bm.to_mesh(me); bm.free()
    o = bpy.data.objects.new(name, me); o.location = loc; bpy.context.collection.objects.link(o)
    o.data.materials.append(mat); return o
def box(name, c, d, mat, rot=None):
    bm = bmesh.new(); bmesh.ops.create_cube(bm, size=1.0)
    M = mathutils.Matrix.Diagonal((d[0], d[1], d[2], 1.0))
    if rot: M = mathutils.Euler(rot).to_matrix().to_4x4() @ M
    bmesh.ops.transform(bm, matrix=M, verts=bm.verts)
    return _obj(name, bm, mat, c)
def span(name, x0, x1, y0, y1, z0, z1, mat):
    return box(name, ((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2), (x1 - x0, y1 - y0, z1 - z0), mat)
def cyl(name, c, r, h, mat, rot=None, verts=24):
    bm = bmesh.new(); bmesh.ops.create_cone(bm, cap_ends=True, segments=verts, radius1=r, radius2=r, depth=h)
    if rot: bmesh.ops.transform(bm, matrix=mathutils.Euler(rot).to_matrix().to_4x4(), verts=bm.verts)
    return _obj(name, bm, mat, c)
def cone(name, c, r1, r2, h, mat, verts=16):
    bm = bmesh.new(); bmesh.ops.create_cone(bm, cap_ends=True, segments=verts, radius1=r1, radius2=r2, depth=h)
    return _obj(name, bm, mat, c)
def blob(name, c, r, mat, seed, amp):
    """でこぼこの球（広葉樹の葉の塊）。ノイズで頂点を押し出す"""
    bm = bmesh.new(); bmesh.ops.create_icosphere(bm, subdivisions=2, radius=r)
    rr = np.random.default_rng(seed)
    ph = rr.uniform(0, 100, 3)
    for v in bm.verts:
        n = math.sin(v.co.x * 3.1 + ph[0]) * math.cos(v.co.y * 2.7 + ph[1]) + 0.6 * math.sin(v.co.z * 4.3 + ph[2]) + 0.5 * math.sin((v.co.x + v.co.y) * 6.0)
        v.co += v.co.normalized() * (n * amp)
    for f in bm.faces: f.smooth = True
    return _obj(name, bm, mat, c)
def hull(name, pts, mat):
    bm = bmesh.new()
    for p in pts: bm.verts.new(p)
    bmesh.ops.convex_hull(bm, input=bm.verts)
    me = bpy.data.meshes.new(name); bm.to_mesh(me); bm.free()
    o = bpy.data.objects.new(name, me); bpy.context.collection.objects.link(o); o.data.materials.append(mat); return o
def prism_y(name, y0, y1, poly_xz, mat):
    """y 方向に押し出した多角形（xz 平面の断面）"""
    pts = [(x, y0, z) for x, z in poly_xz] + [(x, y1, z) for x, z in poly_xz]
    return hull(name, pts, mat)
def prism_x(name, x0, x1, poly_yz, mat):
    pts = [(x0, y, z) for y, z in poly_yz] + [(x1, y, z) for y, z in poly_yz]
    return hull(name, pts, mat)
def cut(target, cutter):
    mod = target.modifiers.new("cut", 'BOOLEAN'); mod.operation = 'DIFFERENCE'; mod.object = cutter; mod.solver = 'EXACT'
    bpy.context.view_layer.objects.active = target; bpy.ops.object.modifier_apply(modifier=mod.name)
    bpy.data.objects.remove(cutter, do_unlink=True)
def smooth(o):
    for pg in o.data.polygons: pg.use_smooth = True

# ============ 地形 ============
def terrain():
    nx, ny = 140, 220
    xs = np.linspace(-45, 45, nx); ys = np.linspace(-60, 25, ny)
    bm = bmesh.new(); verts = []
    for j, yv in enumerate(ys):
        row = []
        for i, xv in enumerate(xs):
            z = ground_z(xv, yv)
            inside = abs(xv) < W / 2 + 1.6 and Y_END - 1.5 < yv < 1.5
            if in_pond(xv, yv, 0.3): z = min(z, POND["z"] - 1.7)
            if abs(xv) < W / 2 + 0.3 and Y_END - 0.3 < yv < 0.3:
                for L in LV:
                    if L["y1"] - STAIR - 0.3 <= yv <= L["y0"] + 0.3: z = min(z, L["z"] - 0.5 - (0 if yv >= L["y1"] else DROP))
            if not inside:
                dx = max(0.0, abs(xv) - W / 2 - 1.6) / 6.0; dy = max(0.0, yv - 1.5, Y_END - 1.5 - yv) / 6.0
                f = min(1.0, math.hypot(dx, dy)) * (0.25 if abs(yv - Y_STREAM) < 4 else 1.0)
                z += f * (0.45 * math.sin(xv * 0.22) * math.cos(yv * 0.17) + 0.18 * math.sin(xv * 0.9 + yv * 0.7))
            row.append(bm.verts.new((xv, yv, z)))
        verts.append(row)
    for j in range(ny - 1):
        for i in range(nx - 1):
            bm.faces.new((verts[j][i], verts[j][i + 1], verts[j + 1][i + 1], verts[j + 1][i]))
    # 底と側面を足して閉じる（断面で土が見える）
    zb = -40.0
    rim = [verts[0][i] for i in range(nx)] + [verts[j][nx - 1] for j in range(1, ny)] + [verts[ny - 1][i] for i in range(nx - 2, -1, -1)] + [verts[j][0] for j in range(ny - 2, 0, -1)]
    bot = [bm.verts.new((v.co.x, v.co.y, zb)) for v in rim]
    for i in range(len(rim)):
        a, b2 = rim[i], rim[(i + 1) % len(rim)]; c, d = bot[(i + 1) % len(rim)], bot[i]
        bm.faces.new((a, d, c, b2))
    bm.faces.new(list(reversed(bot)))
    bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
    me = bpy.data.meshes.new("terrain"); bm.to_mesh(me); bm.free()
    o = bpy.data.objects.new("terrain", me); bpy.context.collection.objects.link(o)
    o.data.materials.append(M_GROUND); smooth(o)
    # 谷川（川床の底より 0.45 上に水面）
    zw = ground_z(0, Y_STREAM) + 0.88
    span("stream", -45, 45, Y_STREAM - 3.2, Y_STREAM + 3.2, zw - 2.0, zw, M_WATER)
    # 池と堰
    span("pond", POND["x0"], POND["x1"], POND["y0"], POND["y1"], POND["z"] - 1.2, POND["z"], M_POND)
    span("pond_floor", POND["x0"] - 0.3, POND["x1"] + 0.3, POND["y0"] - 0.3, POND["y1"] + 0.3, POND["z"] - 1.4, POND["z"] - 1.2, M_BLACK)
    span("weir", POND["x0"] - 0.4, POND["x1"] + 0.4, POND["y0"] - 0.45, POND["y0"] + 0.05, zw - 1.5, POND["z"] + 0.06, M_MASONRY)
    for sx in (POND["x0"], POND["x1"]):
        span(f"pond_wall{sx:.0f}", sx - 0.4 if sx < 0 else sx, sx if sx < 0 else sx + 0.4, POND["y0"] - 0.45, POND["y1"] + 0.4, POND["z"] - 1.3, POND["z"] + 0.06, M_MASONRY)
    # 川の石
    r = np.random.default_rng(3); B = Batch()
    for i in range(90):
        x = r.uniform(-30, 30); y = Y_STREAM + r.uniform(-3.4, 3.4); rr = r.uniform(0.12, 0.55)
        if math.hypot(x - 5.0, y - Y_STREAM + 5.0) < 3.0 or in_pond(x, y, 0.6): continue     # 外観のカメラの足元・池
        B._ph = r.uniform(0, 100, 3); B.blob((x, y, ground_z(x, y) + rr * 0.3), rr, r, 0.3)
    B.obj("rocks", M_STONE, smooth=True)
    # 遠景: 地形の外側に大きな尾根を重ねる（見張り台と外観の地平線を埋める）
    bm = bmesh.new(); nx2, ny2 = 90, 90; vs2 = []
    for j in range(ny2):
        row = []
        for i in range(nx2):
            xv = -600 + 1200 * i / (nx2 - 1); yv = -700 + 1200 * j / (ny2 - 1)
            d = math.hypot(xv, yv + 20)
            base = ground_z(max(-45, min(45, xv)), max(-60, min(25, yv)))
            ridge = 0.0
            if d > 40:
                t = min(1.0, (d - 40) / 200.0)
                ridge = t * (28 * math.sin(xv * 0.011 + 1.3) * math.cos(yv * 0.009) + 14 * math.sin(xv * 0.031 + yv * 0.023) + 6 * math.sin(xv * 0.07 - yv * 0.05)) + t * 0.05 * d
                if yv > 0: ridge += t * 0.09 * yv       # 北は山が高い
                if yv < Y_STREAM: ridge += t * 0.03 * (Y_STREAM - yv)
            z = base + ridge - 2.0 * (d > 40) * min(1.0, (d - 40) / 20.0)
            row.append(bm.verts.new((xv, yv, z)))
        vs2.append(row)
    for j in range(ny2 - 1):
        for i in range(nx2 - 1):
            if abs(vs2[j][i].co.x) < 44 and -59 < vs2[j][i].co.y < 24 and abs(vs2[j + 1][i + 1].co.x) < 44 and -59 < vs2[j + 1][i + 1].co.y < 24: continue
            bm.faces.new((vs2[j][i], vs2[j][i + 1], vs2[j + 1][i + 1], vs2[j + 1][i]))
    me = bpy.data.meshes.new("far_hills"); bm.to_mesh(me); bm.free()
    o = bpy.data.objects.new("far_hills", me); bpy.context.collection.objects.link(o); o.data.materials.append(M_FAR); smooth(o)
    # 道路（砂利）
    span("road", -45, 45, 2.2, 6.5, ground_z(0, 3) - 0.6, ground_z(0, 3) + 0.02, M_GRAVEL)

# ============ 木 ============
class Batch:
    """頂点と面を積んで最後に一つのメッシュにする"""
    def __init__(self): self.v = []; self.f = []
    def skirt(self, c, rad, h, n, rr):
        """針葉の皿: 中心から放射状に、長さの違う細い三角を垂らす"""
        cx, cy, cz = c
        for k in range(n):
            a = 2 * math.pi * k / n + rr.uniform(-0.1, 0.1); L = rad * rr.uniform(0.55, 1.15); w = 0.35 * L * math.tan(math.pi / n) * 2
            d = mathutils.Vector((math.cos(a), math.sin(a), 0)); side = mathutils.Vector((-math.sin(a), math.cos(a), 0)) * w
            p0 = mathutils.Vector((cx, cy, cz)); tip = p0 + d * L - mathutils.Vector((0, 0, h * 0.35 + L * 0.22))
            self.quad(p0 - side * 0.3, p0 + side * 0.3, tip + side * 0.5, tip - side * 0.5)
            # 小枝: 主枝の途中から左右へ短い葉
            for s_ in (-1, 1):
                for t_ in (0.45, 0.75):
                    b0 = p0 + (tip - p0) * t_; b1 = b0 + side.normalized() * s_ * L * 0.22 - mathutils.Vector((0, 0, L * 0.08)); sw = side * 0.25
                    self.quad(b0 - sw, b0 + sw, b1 + sw * 0.4, b1 - sw * 0.4)
    def cone(self, c, r1, r2, h, segs, rr=None, droop=0.0, jitter=0.0):
        b = len(self.v); cx, cy, cz = c
        for k in range(segs):
            a = 2 * math.pi * k / segs
            for (rad, zz) in ((r1, -h / 2), (r2, h / 2)):
                x = cx + math.cos(a) * rad; y = cy + math.sin(a) * rad; z = cz + zz - (droop * h if rad == r1 else 0.0)
                if jitter and rr is not None: x += rr.uniform(-jitter, jitter); y += rr.uniform(-jitter, jitter); z += rr.uniform(-jitter, jitter)
                self.v.append((x, y, z))
        for k in range(segs):
            k2 = (k + 1) % segs
            self.f.append((b + 2 * k, b + 2 * k2, b + 2 * k2 + 1, b + 2 * k + 1))
        self.v.append((cx, cy, cz + h / 2)); top = len(self.v) - 1
        for k in range(segs): self.f.append((b + 2 * k + 1, b + 2 * ((k + 1) % segs) + 1, top))
        self.v.append((cx, cy, cz - h / 2 - droop * h)); bot = len(self.v) - 1
        for k in range(segs): self.f.append((b + 2 * ((k + 1) % segs), b + 2 * k, bot))
    def quad(self, p0, p1, p2, p3):
        b = len(self.v); self.v += [tuple(p0), tuple(p1), tuple(p2), tuple(p3)]; self.f.append((b, b + 1, b + 2, b + 3))
    def blob(self, c, rad, rr, amp):
        b = len(self.v)
        for (x, y, z) in ICO_V:
            nn = math.sin(x * 5.1 + self._ph[0]) * math.cos(y * 4.7 + self._ph[1]) + 0.6 * math.sin(z * 6.3 + self._ph[2])
            k = rad * (1 + amp * nn)
            self.v.append((c[0] + x * k, c[1] + y * k, c[2] + z * k))
        for (i, j, l) in ICO_F: self.f.append((b + i, b + j, b + l))
    def obj(self, name, mat, smooth=False):
        me = bpy.data.meshes.new(name); me.from_pydata(self.v, [], self.f); me.update()
        if smooth:
            me.polygons.foreach_set("use_smooth", [True] * len(me.polygons))
        o = bpy.data.objects.new(name, me); bpy.context.collection.objects.link(o); o.data.materials.append(mat); return o
_bm = bmesh.new(); bmesh.ops.create_icosphere(_bm, subdivisions=2, radius=1.0); _bm.verts.ensure_lookup_table(); _bm.faces.ensure_lookup_table()
ICO_V = [tuple(v.co) for v in _bm.verts]; ICO_F = [tuple(v.index for v in f.verts) for f in _bm.faces]; _bm.free()
def _cone_into(bm, c, r1, r2, h, segs, rr, droop=0.0, jitter=0.0):
    """bm にずらした円錐を足す（針葉の皿: 縁をたらし、頂点をゆらす）"""
    g = bmesh.ops.create_cone(bm, cap_ends=True, segments=segs, radius1=r1, radius2=r2, depth=h)
    vs = g["verts"]
    for v in vs:
        d = math.hypot(v.co.x, v.co.y)
        v.co.z -= droop * (d / max(r1, 1e-3)) ** 2 * h
        if jitter: v.co += mathutils.Vector(rr.uniform(-jitter, jitter, 3))
        v.co += mathutils.Vector(c)
def cedar(x, y, h, seed):
    r = np.random.default_rng(seed)
    z0 = ground_z(x, y) - 0.3
    bm = bmesh.new()
    # 幹（先細り）
    g = bmesh.ops.create_cone(bm, cap_ends=True, segments=10, radius1=0.08 + h * 0.014, radius2=0.03, depth=h)
    for v in g["verts"]: v.co.z += z0 + h * 0.5; v.co.x += x; v.co.y += y
    trunk = _obj(f"cedar_t{seed}", bm, M_BARK)
    B = Batch()
    # 針葉の塊: 円錐の表面近くに小さな葉の板を密に散らす（遠くからは濃い円錐、近くでは葉の重なり）
    lean = r.uniform(-0.03, 0.03, 2)
    N = int(90 * h); R0 = 0.8 + 0.014 * h; ls = 0.16 + h * 0.006
    for i in range(N):
        t = r.uniform(0, 1) ** 0.8                     # 0=裾 1=梢。裾のほうに多く
        zz = z0 + h * (0.3 + 0.7 * t)
        rad = (0.12 + 0.88 * (1 - t) ** 1.1) * R0 * r.uniform(0.9, 1.1)
        d = rad * (0.55 + 0.45 * r.uniform(0, 1) ** 0.5)  # 表面寄り
        a = r.uniform(0, 6.3)
        c = mathutils.Vector((x + lean[0] * (zz - z0) + math.cos(a) * d, y + lean[1] * (zz - z0) + math.sin(a) * d, zz))
        out = mathutils.Vector((math.cos(a), math.sin(a), -0.5)).normalized()     # 外向きで少し垂れる
        side = mathutils.Vector((-math.sin(a), math.cos(a), 0))
        u = out * ls * r.uniform(0.8, 1.3); v = side * ls * r.uniform(0.35, 0.6)
        B.quad(c - v, c + v, c + u + v * 0.5, c + u - v * 0.5)
        # 直交する板で厚みを出す
        w = out.cross(side).normalized() * ls * 0.3
        B.quad(c - w, c + w, c + u + w * 0.4, c + u - w * 0.4)
    B.obj(f"cedar_c{seed}", M_CEDAR_LEAF)
    M_CEDAR_LEAF.use_backface_culling = False
def broadleaf(x, y, h, seed, near=False):
    r = np.random.default_rng(seed)
    z0 = ground_z(x, y) - 0.3
    W_ = Batch(); Lf = Batch(); tips = []
    def branch(p, d, L, rad, lvl):
        q = p + d * L
        W_.cone(((p.x + q.x) / 2, (p.y + q.y) / 2, (p.z + q.z) / 2), rad, rad * 0.6, L, 6)   # 枝（向きは後で）
        # cone は z 向きに作られるので、向きを合わせるために頂点を回す
        n = 6 * 2 + 2; vs = W_.v[-n:]; M = d.to_track_quat('Z', 'Y').to_matrix(); c = mathutils.Vector(((p.x + q.x) / 2, (p.y + q.y) / 2, (p.z + q.z) / 2))
        for i in range(n): v = mathutils.Vector(vs[i]) - c; W_.v[-n + i] = tuple(M @ v + c)
        if lvl >= 3 or L < 0.5: tips.append((q, d)); return
        nb = 3 if lvl < 2 else 2
        for k in range(nb):
            a = r.uniform(0, 6.3); spread = r.uniform(0.45, 0.9)
            side = mathutils.Vector((math.cos(a), math.sin(a), 0)); nd = (d * math.cos(spread) + side * math.sin(spread) + mathutils.Vector((0, 0, 0.25))).normalized()
            branch(q, nd, L * r.uniform(0.55, 0.75), rad * 0.62, lvl + 1)
        if lvl >= 1: tips.append((q, d))
    trunk_h = h * 0.42
    branch(mathutils.Vector((x, y, z0)), mathutils.Vector((r.uniform(-0.08, 0.08), r.uniform(-0.08, 0.08), 1)).normalized(), trunk_h, 0.10 + h * 0.014, 0)
    W_.obj(f"tree_t{seed}", M_BARK)
    # 葉: 枝先ごとに塊で散らす
    per = 70 if near else 36; ls = 0.11 + h * 0.004
    for (q, d) in tips:
        for i in range(per):
            off = mathutils.Vector(r.normal(0, 1, 3)); off.z *= 0.6; off = off.normalized() * r.uniform(0, 1) ** 0.5 * (0.5 + h * 0.03)
            c = q + off; a = r.uniform(0, 6.3); tl = r.uniform(-1.2, 1.2)
            u = mathutils.Vector((math.cos(a), math.sin(a), 0)) * ls; v = mathutils.Vector((-math.sin(a) * math.cos(tl), math.cos(a) * math.cos(tl), math.sin(tl))) * ls * 0.65
            Lf.quad(c - u - v, c + u - v, c + u + v, c - u + v)
    Lf.obj(f"tree_c{seed}", [M_LEAF_A, M_LEAF_B, M_LEAF_C][int(r.integers(0, 3))])
def forest():
    r = np.random.default_rng(5)
    k = 0
    for _ in range(190):
        x = r.uniform(-44, 44); y = r.uniform(-58, 22)
        if abs(x) < W / 2 + 3.0 and Y_END - 5 < y < 2.0: continue     # 家と前庭
        if 2.0 <= y <= 6.8: continue                                    # 道路
        if abs(y - Y_STREAM) < 4.5: continue                            # 川の上に木は立たない
        if y < Y_END - 3 and y > Y_STREAM and abs(x) < 7: continue        # 家の南の水辺の開き
        h = r.uniform(10, 20)
        if r.uniform() < 0.6: cedar(x, y, h, 100 + k)
        else: broadleaf(x, y, h * 0.75, 300 + k, near=(abs(x) < 16 and y > Y_STREAM - 14))
        k += 1
    # 対岸（水の間の正面）に木を足す
    for j in range(26):
        x = r.uniform(-13, 13); y = Y_STREAM - 4.8 - r.uniform(0, 10)
        if math.hypot(x - 5.0, y - Y_STREAM + 7.0) < 5.0: continue     # 外観のカメラの周り
        h = r.uniform(9, 17)
        if r.uniform() < 0.5: cedar(x, y, h, 100 + k)
        else: broadleaf(x, y, h * 0.75, 300 + k, near=True)
        k += 1
    # 下草: 草の株と羊歯を一つのメッシュに
    B = Batch(); F = Batch()
    for _ in range(9000):
        x = r.uniform(-30, 30); y = r.uniform(-50, 8)
        if abs(x) < W / 2 + 0.3 and Y_END - 3 < y < 1.0: continue
        if abs(y - Y_STREAM) < 3.2 or 2.0 <= y <= 6.8 or in_pond(x, y, 0.6): continue
        z = ground_z(x, y)
        if r.uniform() < 0.8:
            for j in range(3):
                a = r.uniform(0, 6.3); hh = r.uniform(0.12, 0.4); tilt = r.uniform(0.2, 0.7)
                p0 = mathutils.Vector((x + r.uniform(-0.08, 0.08), y + r.uniform(-0.08, 0.08), z)); d = mathutils.Vector((math.cos(a) * tilt, math.sin(a) * tilt, 1.0)).normalized()
                side = mathutils.Vector((-math.sin(a), math.cos(a), 0)) * 0.012
                B.quad(p0 - side, p0 + side, p0 + d * hh + side * 0.2, p0 + d * hh - side * 0.2)
        else:
            # 羊歯: 放射状の細い葉 6 枚
            for j in range(6):
                a = j * 1.05 + r.uniform(-0.3, 0.3); L = r.uniform(0.3, 0.7)
                p0 = mathutils.Vector((x, y, z + 0.1)); d = mathutils.Vector((math.cos(a), math.sin(a), 0.35)).normalized()
                side = mathutils.Vector((-math.sin(a), math.cos(a), 0)) * 0.07
                F.quad(p0 - side, p0 + side, p0 + d * L + side * 0.3 - mathutils.Vector((0, 0, L * 0.25)), p0 + d * L - side * 0.3 - mathutils.Vector((0, 0, L * 0.25)))
    for _ in range(3500):
        a = r.uniform(0, 6.3); d = r.uniform(0, 1) ** 0.5 * 7.0
        x = 5.0 + math.cos(a) * d; y = Y_STREAM - 5.0 + math.sin(a) * d
        if abs(y - Y_STREAM) < 2.8 or math.hypot(x - 5.0, y - Y_STREAM + 7.0) < 2.5 or in_pond(x, y, 0.6): continue
        z = ground_z(x, y)
        for j in range(4):
            a2 = r.uniform(0, 6.3); hh = r.uniform(0.15, 0.5); tilt = r.uniform(0.2, 0.8)
            p0 = mathutils.Vector((x + r.uniform(-0.1, 0.1), y + r.uniform(-0.1, 0.1), z)); dd = mathutils.Vector((math.cos(a2) * tilt, math.sin(a2) * tilt, 1.0)).normalized()
            side = mathutils.Vector((-math.sin(a2), math.cos(a2), 0)) * 0.012
            B.quad(p0 - side, p0 + side, p0 + dd * hh + side * 0.2, p0 + dd * hh - side * 0.2)
    B.obj("grass_tufts", M_GRASS_TUFT); F.obj("ferns", M_FERN)

# ============ 家 ============
def roof_plane(k):
    """段 k の屋根: 北端 zN（部屋の天井高＋梁）、南へ PITCH で下がる。軒は三方に EAVE"""
    L = LV[k]; yN = L["y0"] + (EAVE if k == 0 else 0.0); yS = L["y1"] - EAVE
    zN = L["z"] + L["h"] + 0.3
    def z_at(yy): return zN - (yN - yy) * PITCH
    return yN, yS, zN, z_at

def house():
    for k, L in enumerate(LV):
        y0, y1, z, h = L["y0"], L["y1"], L["z"], L["h"]
        yN, yS, zN, z_at = roof_plane(k)
        last = k == len(LV) - 1
        # 床（最下段は石、他は杉板）。階段部分は別
        span(f"floor{k}", -WI / 2, WI / 2, y1, y0, z - 0.06, z, M_SLATE if last else M_CEDAR)
        # 基礎: 北半分は地面に食い込む石の擁壁、南半分は床下が空いて柱で立つ（斜面に伏せた段々）
        ymid = (y0 + y1) / 2
        span(f"base{k}", -W / 2, W / 2, ymid, y0, min(ground_z(0, ymid) - 1.0, z - 2.5), z - 0.06, M_MASONRY)
        span(f"base_s{k}", -W / 2, W / 2, y1 - (STAIR if not last else 0), ymid, min(ground_z(0, y1) - 1.0, z - 2.5), z - 0.45, M_MASONRY)
        span(f"sill{k}", -W / 2, W / 2, y1 - (STAIR if not last else 0), y0, z - 0.45, z - 0.06, M_YAKISUGI)
        # 側壁: 外は焼杉、内は漆喰（二重）。高さは屋根の裏まで（上端を屋根勾配に合わせて多角形）
        for sx, nm in ((-1, "W"), (1, "E")):
            xo = sx * W / 2; xi = sx * (WI / 2)
            poly = [(y0, z - 0.3), (y1 - (0 if last else STAIR), z - DROP - 0.3 if not last else z - 0.3), (y1 - (0 if last else STAIR), z_at(y1 - (0 if last else STAIR)) + 0.02), (y0, z_at(y0) + 0.02)]
            prism_x(f"wall_{nm}{k}_out", min(xo, xi + sx * 0.12), max(xo, xi + sx * 0.12), poly, M_YAKISUGI)
            prism_x(f"wall_{nm}{k}_in", min(xi, xi + sx * 0.12), max(xi, xi + sx * 0.12), poly, M_PLASTER)
        # 北の壁（段 0 だけ外壁。他の段は上の段との境で、階段の上は開いている）
        if k == 0:
            prism_y(f"wall_N_out", y0, y0 + T, [(-W / 2, z - 0.3), (W / 2, z - 0.3), (W / 2, z_at(y0) + 0.02), (-W / 2, z_at(y0) + 0.02)], M_YAKISUGI)
            prism_y(f"wall_N_in", y0 - 0.12, y0, [(-WI / 2, z - 0.3), (WI / 2, z - 0.3), (WI / 2, z_at(y0) + 0.02), (-WI / 2, z_at(y0) + 0.02)], M_PLASTER)
        # 屋根（厚い板、軒先は薄く見せるため下面に勾配をつけない）
        prism_x(f"roof{k}", -W / 2 - EAVE, W / 2 + EAVE, [(yN, zN), (yS, z_at(yS)), (yS, z_at(yS) - ROOF_T), (yN, zN - ROOF_T)], M_ROOF)
        # 天井の板（屋根の裏、室内側）
        prism_x(f"ceil{k}", -WI / 2, WI / 2, [(y0 + 0.02, z_at(y0 + 0.02) - ROOF_T), (y1 - (0 if last else STAIR) - 0.02, z_at(y1 - (0 if last else STAIR) - 0.02) - ROOF_T), (y1 - (0 if last else STAIR) - 0.02, z_at(y1 - (0 if last else STAIR) - 0.02) - ROOF_T - 0.03), (y0 + 0.02, z_at(y0 + 0.02) - ROOF_T - 0.03)], M_CEIL)
        if k == 0:
            for nm in ("roof0", "ceil0"):
                c0 = span("cutter", WI / 2 - 0.55, WI / 2 - 0.2, y1 + 0.6, y0 - 0.6, z, z + 8, M_IRON); cut(bpy.data.objects[nm], c0)
            span("skylight0", WI / 2 - 0.55, WI / 2 - 0.2, y1 + 0.6, y0 - 0.6, z_at(y0 - 0.6) - 0.05, z_at(y0 - 0.6) - 0.01, M_GLASS)
        # 柱と梁（内法の壁面に柱 150 角、1.8m 間隔。梁は柱の頭）
        yy = y0 - 0.2
        while yy > y1 - (0 if last else STAIR) + 0.1:
            for sx in (-1, 1):
                xp = sx * (WI / 2 - 0.09)
                span(f"post{k}_{sx}_{round(-yy, 1)}", xp - 0.075, xp + 0.075, yy - 0.075, yy + 0.075, z - 0.06, z_at(yy) - ROOF_T, M_POST)
            # 梁（屋根の裏に沿う）
            prism_x(f"beam{k}_{round(-yy, 1)}", -WI / 2, WI / 2, [(yy - 0.08, z_at(yy) - ROOF_T - 0.3), (yy + 0.08, z_at(yy) - ROOF_T - 0.3), (yy + 0.08, z_at(yy) - ROOF_T - 0.02), (yy - 0.08, z_at(yy) - ROOF_T - 0.02)], M_BEAM)
            yy -= 1.8
        # 貫（柱を貫く横木、床上 0.9 と 1.9）
        for zz in (0.9, 1.9):
            if zz < h - 0.3:
                for sx in (-1, 1):
                    xp = sx * (WI / 2 - 0.09)
                    span(f"nuki{k}_{sx}_{zz}", xp - 0.05, xp + 0.05, y1 - (0 if last else STAIR) + 0.05, y0 - 0.05, z + zz, z + zz + 0.1, M_BEAM)
        # 階段（部屋の南端から次の段へ、幅いっぱい 5 段）
        if not last:
            for s in range(5):
                ys0 = y1 - s * 0.3; ys1 = ys0 - 0.3; zs = z - (s + 1) * 0.3
                span(f"step{k}_{s}", -WI / 2, WI / 2, ys1, ys0, zs - 0.06, zs, M_CEDAR)
                span(f"riser{k}_{s}", -WI / 2, WI / 2, ys0 - 0.02, ys0, zs - 0.06, zs + 0.3 - 0.06, M_CEDAR)
            # 光の段: 上の段の屋根の南端と下の段の屋根の北端のあいだに立つガラス（部屋の境 y1）
            yNn, ySn, zNn, z_at_n = roof_plane(k + 1)
            zb0 = zNn + 0.02; zb1 = z_at(y1) - ROOF_T - 0.02
            if zb1 > zb0 + 0.2:
                # 南向きの光の段: 上の屋根の裏と下の屋根の上のあいだのガラス
                span(f"band{k}", -WI / 2, WI / 2, y1 - 0.03, y1 + 0.03, zb0, zb1, M_GLASS)
                for xx in np.linspace(-WI / 2, WI / 2, 5):
                    span(f"band_mull{k}_{round(xx, 1)}", xx - 0.03, xx + 0.03, y1 - 0.05, y1 + 0.05, zb0, zb1, M_IRON)
                span(f"band_sill{k}", -W / 2, W / 2, y1 - 0.08, y1 + 0.08, zb0 - 0.08, zb0, M_IRON)
            else:
                # 次の部屋のほうが高い: 上の屋根の上に立ち上がる北の壁。上部 1.0m は北向きの高窓（上の斜面からの柔らかい光）
                zt = zNn - 0.02; zw = z_at(y1) + 0.02; zg = zt - 1.0
                prism_y(f"north_wall{k + 1}_out", y1 - T, y1, [(-W / 2, zw - 0.3), (W / 2, zw - 0.3), (W / 2, zg), (-W / 2, zg)], M_YAKISUGI)
                prism_y(f"north_wall{k + 1}_in", y1 - T - 0.12, y1 - T, [(-WI / 2, zw - 0.3), (WI / 2, zw - 0.3), (WI / 2, zg), (-WI / 2, zg)], M_PLASTER)
                span(f"north_glass{k + 1}", -WI / 2, WI / 2, y1 - T / 2 - 0.02, y1 - T / 2 + 0.02, zg, zt, M_GLASS)
                for xx in np.linspace(-WI / 2, WI / 2, 5):
                    span(f"north_mull{k + 1}_{round(xx, 1)}", xx - 0.03, xx + 0.03, y1 - T / 2 - 0.05, y1 - T / 2 + 0.05, zg, zt, M_IRON)
                span(f"north_sill{k + 1}", -W / 2, W / 2, y1 - T, y1 + 0.1, zg - 0.08, zg, M_IRON)
            # 下の段の屋根の北端の壁（屋根の上の立ち上がり、焼杉）はガラスの下にある: 屋根 k+1 の北端から段 k の床までの外壁
            for sx in (-1, 1):
                pass
        # 南端の開口（最下段: 全面ガラスの引き戸を開け放つ。他の段は階段の上の空気）
        if last:
            span(f"glass_S", -WI / 2, WI / 2, y1 - 0.03, y1 + 0.03, z, z_at(y1) - ROOF_T - 0.02, M_GLASS)
            for xx in np.linspace(-WI / 2, WI / 2, 4):
                span(f"mull_S_{round(xx, 1)}", xx - 0.03, xx + 0.03, y1 - 0.05, y1 + 0.05, z, z_at(y1) - ROOF_T, M_IRON)
            # 縁（石のテラス）と水
            span("terrace", -W / 2 - 0.6, W / 2 + 0.6, y1 - 2.6, y1 + 0.05, z - 0.14, z - 0.02, M_SLATE)
            span("terrace_edge", -W / 2 - 0.6, W / 2 + 0.6, y1 - 2.6, y1 + 0.05, z - 0.6, z - 0.14, M_MASONRY)
            # 川へ降りる石段（蹴上 0.3、踏面 0.6、地面に沿って）
            zz = z - 0.6; yy = y1 - 2.6
            while zz > ground_z(0, yy) - 0.5 and yy > Y_STREAM + 3.0:
                span(f"stone_step{round(-yy, 1)}", -2.2, 2.2, yy - 0.6, yy, zz - 0.3, zz, M_STONE); zz -= 0.3; yy -= 0.6
        # 側壁の小窓（各段に 1〜2）
        for sx in (-1, 1):
            xo = sx * W / 2
            for (wy, wz, ww, wh) in ([(y0 - 1.0, 1.6, 0.4, 1.0)] if k == 0 else [(y0 - 2.6, 0.5, 1.4, 0.6)] if k == 3 else [(y0 - 1.3, 1.3, 0.6, 1.2), (y0 - 3.8, 1.3, 0.6, 1.2)]):
                hole = span("hole", xo - 0.5, xo + 0.5, wy - ww / 2, wy + ww / 2, z + wz - wh / 2, z + wz + wh / 2, M_IRON)
                for nm in (f"wall_{'W' if sx < 0 else 'E'}{k}_out", f"wall_{'W' if sx < 0 else 'E'}{k}_in"):
                    cutter = span("cutter", xo - 0.5, xo + 0.5, wy - ww / 2, wy + ww / 2, z + wz - wh / 2, z + wz + wh / 2, M_IRON)
                    cut(bpy.data.objects[nm], cutter)
                bpy.data.objects.remove(hole, do_unlink=True)
                span(f"win{k}_{sx}_{round(-wy, 1)}", sx * (W / 2 - 0.1) - 0.02, sx * (W / 2 - 0.1) + 0.02, wy - ww / 2, wy + ww / 2, z + wz - wh / 2, z + wz + wh / 2, M_GLASS)
                # 枠
                for (a0, a1, b0, b1) in ((wy - ww / 2 - 0.04, wy - ww / 2, z + wz - wh / 2 - 0.04, z + wz + wh / 2 + 0.04), (wy + ww / 2, wy + ww / 2 + 0.04, z + wz - wh / 2 - 0.04, z + wz + wh / 2 + 0.04), (wy - ww / 2, wy + ww / 2, z + wz - wh / 2 - 0.04, z + wz - wh / 2), (wy - ww / 2, wy + ww / 2, z + wz + wh / 2, z + wz + wh / 2 + 0.04)):
                    span(f"frame{k}_{sx}_{round(-wy, 1)}_{round(a0, 2)}", sx * W / 2 - 0.16, sx * W / 2 + 0.02, a0, a1, b0, b1, M_IRON)
    # 道路側のスリット（段 0 の北壁、細い縦長）
    for xx in (-1.6, 1.6):
        for nm in ("wall_N_out", "wall_N_in"):
            cutter = span("cutter", xx - 0.09, xx + 0.09, -0.5, 0.6, 0.4, 2.0, M_IRON); cut(bpy.data.objects[nm], cutter)
        span(f"slit{xx}", xx - 0.09, xx + 0.09, 0.1, 0.14, 0.4, 2.0, M_GLASS)

def tower():
    """段 2（居間）の東寄りに立つ見張り塔。木の格子の柱 4 本、中に梯子、屋根の上に見張り台"""
    L = LV[2]; cx, cy = 1.55, L["y0"] - 3.0; s = 0.8
    zb = L["z"]; zt = LV[0]["z"] + LV[0]["h"] + 0.3 + 3.2
    for sx in (-1, 1):
        for sy in (-1, 1):
            span(f"tower_post{sx}{sy}", cx + sx * s - 0.075, cx + sx * s + 0.075, cy + sy * s - 0.075, cy + sy * s + 0.075, zb, zt + 0.6, M_POST)
    # 格子（四面に横桟 0.4 間隔、屋根より下だけ）
    zz = zb + 0.4
    while zz < zt - 0.2:
        span(f"tower_rail_n{zz:.1f}", cx - s, cx + s, cy + s - 0.03, cy + s + 0.03, zz, zz + 0.06, M_BEAM)
        span(f"tower_rail_s{zz:.1f}", cx - s, cx + s, cy - s - 0.03, cy - s + 0.03, zz, zz + 0.06, M_BEAM)
        span(f"tower_rail_w{zz:.1f}", cx - s - 0.03, cx - s + 0.03, cy - s, cy + s, zz, zz + 0.06, M_BEAM)
        zz += 0.4
    # 梯子（西面の内側）
    span("ladder_l", cx - s + 0.3, cx - s + 0.35, cy - 0.3, cy - 0.25, zb, zt, M_OAK)
    span("ladder_r", cx - s + 0.3, cx - s + 0.35, cy + 0.25, cy + 0.3, zb, zt, M_OAK)
    zz = zb + 0.3
    while zz < zt - 0.3:
        span(f"rung{zz:.1f}", cx - s + 0.3, cx - s + 0.35, cy - 0.3, cy + 0.3, zz, zz + 0.035, M_OAK); zz += 0.3
    # 見張り台（床と四方の小窓、小さな片流れ屋根）
    span("lookout_floor", cx - s - 0.2, cx + s + 0.2, cy - s - 0.2, cy + s + 0.2, zt - 0.08, zt, M_CEDAR)
    for sx in (-1, 1):
        prism_x(f"lookout_wall_x{sx}", cx + sx * (s + 0.2) - (0.12 if sx < 0 else 0), cx + sx * (s + 0.2) + (0.12 if sx > 0 else 0), [(cy - s - 0.2, zt), (cy + s + 0.2, zt), (cy + s + 0.2, zt + 0.6), (cy - s - 0.2, zt + 0.6)], M_YAKISUGI)
        prism_y(f"lookout_wall_y{sx}", cy + sx * (s + 0.2) - (0.12 if sx < 0 else 0), cy + sx * (s + 0.2) + (0.12 if sx > 0 else 0), [(cx - s - 0.2, zt), (cx + s + 0.2, zt), (cx + s + 0.2, zt + 0.6), (cx - s - 0.2, zt + 0.6)], M_YAKISUGI)
    # 窓（腰壁の上は四方ガラス）
    for sx in (-1, 1):
        span(f"lookout_glass_x{sx}", cx + sx * (s + 0.2) - 0.01, cx + sx * (s + 0.2) + 0.01, cy - s - 0.2, cy + s + 0.2, zt + 0.6, zt + 2.1, M_GLASS)
        span(f"lookout_glass_y{sx}", cx - s - 0.2, cx + s + 0.2, cy + sx * (s + 0.2) - 0.01, cy + sx * (s + 0.2) + 0.01, zt + 0.6, zt + 2.1, M_GLASS)
    prism_x("lookout_roof", cx - s - 0.5, cx + s + 0.5, [(cy + s + 0.5, zt + 2.5), (cy - s - 0.5, zt + 2.2), (cy - s - 0.5, zt + 2.05), (cy + s + 0.5, zt + 2.35)], M_ROOF)
    # 塔が屋根を貫く所: 屋根に穴
    for k in (0, 1, 2):
        pass
    cutter = span("cutter", cx - s - 0.25, cx + s + 0.25, cy - s - 0.25, cy + s + 0.25, zb, zt - 0.1, M_IRON)
    for nm in ("roof2", "ceil2"):
        c2 = span("cutter2", cx - s - 0.25, cx + s + 0.25, cy - s - 0.25, cy + s + 0.25, zb, zt - 0.1, M_IRON)
        cut(bpy.data.objects[nm], c2)
    bpy.data.objects.remove(cutter, do_unlink=True)
    # 屋根の上の塔の外装（屋根面から見張り台までは焼杉の筒）
    L2 = LV[2]; yN, yS, zN, z_at = roof_plane(2); zr = z_at(cy)
    for sx in (-1, 1):
        prism_x(f"tower_skin_x{sx}", cx + sx * (s + 0.2) - (0.1 if sx < 0 else 0), cx + sx * (s + 0.2) + (0.1 if sx > 0 else 0), [(cy - s - 0.2, zr - 0.3), (cy + s + 0.2, zr - 0.3), (cy + s + 0.2, zt - 0.08), (cy - s - 0.2, zt - 0.08)], M_YAKISUGI)
        prism_y(f"tower_skin_y{sx}", cy + sx * (s + 0.2) - (0.1 if sx < 0 else 0), cy + sx * (s + 0.2) + (0.1 if sx > 0 else 0), [(cx - s - 0.2, zr - 0.3), (cx + s + 0.2, zr - 0.3), (cx + s + 0.2, zt - 0.08), (cx - s - 0.2, zt - 0.08)], M_YAKISUGI)

def furniture():
    # 段 1: 長い食卓とベンチ、台所の流し
    L = LV[1]; z = L["z"]; yc = (L["y0"] + L["y1"]) / 2
    span("table", -0.45, 0.45, yc - 2.0, yc + 2.0, z + 0.70, z + 0.76, M_OAK)
    for yy in (yc - 1.7, yc + 1.7):
        span(f"table_leg{yy:.0f}", -0.38, 0.38, yy - 0.04, yy + 0.04, z, z + 0.70, M_OAK)
    for sx in (-1, 1):
        span(f"bench{sx}", sx * 0.9 - 0.17, sx * 0.9 + 0.17, yc - 1.8, yc + 1.8, z + 0.42, z + 0.46, M_OAK)
        for yy in (yc - 1.5, yc + 1.5): span(f"bench_leg{sx}{yy:.0f}", sx * 0.9 - 0.14, sx * 0.9 + 0.14, yy - 0.03, yy + 0.03, z, z + 0.42, M_OAK)
    span("counter", WI / 2 - 0.65, WI / 2 - 0.09, L["y0"] - 0.3, L["y1"] + 0.3, z, z + 0.88, M_OAK)
    span("counter_top", WI / 2 - 0.68, WI / 2 - 0.09, L["y0"] - 0.28, L["y1"] + 0.28, z + 0.88, z + 0.92, M_STONE)
    for i in range(6):
        cyl(f"bowl{i}", (WI / 2 - 0.38, L["y0"] - 0.8 - i * 0.35, z + 0.95), 0.09, 0.06, M_PAPER, verts=16)
    for i in range(4): cyl(f"tbowl{i}", (rng.uniform(-0.3, 0.3), yc - 1.5 + i * 1.0, z + 0.79), rng.uniform(0.06, 0.1), 0.05, M_PAPER, verts=16)
    # 行灯
    cyl("lamp_d", (0.0, yc, z + 1.55), 0.19, 0.34, M_PAPER, verts=24); cyl("lamp_d_cord", (0.0, yc, z + 2.1), 0.004, 0.8, M_BLACK, verts=6); span("lamp_d_core", -0.05, 0.05, yc - 0.05, yc + 0.05, z + 1.45, z + 1.65, M_LAMP)
    # 段 2: 薪ストーブ、低い長椅子、本棚
    L = LV[2]; z = L["z"]; yc = (L["y0"] + L["y1"]) / 2
    cyl("stove", (-1.6, yc + 0.6, z + 0.55), 0.3, 0.9, M_BLACK)
    cyl("stove_pipe", (-1.6, yc + 0.6, z + 2.4), 0.08, 3.0, M_BLACK, verts=16)
    span("stove_door", -1.6 - 0.18, -1.6 + 0.18, yc + 0.6 - 0.32, yc + 0.6 - 0.29, z + 0.35, z + 0.65, M_EMBER)
    span("stove_base", -2.3, -0.9, yc - 0.2, yc + 1.4, z, z + 0.08, M_STONE)
    span("sofa", -0.9, 1.3, yc - 2.0, yc - 1.2, z + 0.1, z + 0.42, M_FABRIC)
    span("sofa_back", -0.9, 1.3, yc - 2.0, yc - 1.85, z + 0.42, z + 0.8, M_FABRIC)
    for i in range(3): span(f"cushion{i}", -0.8 + i * 0.7, -0.2 + i * 0.7, yc - 1.95, yc - 1.3, z + 0.42, z + 0.5, M_LINEN)
    span("rug", -1.4, 1.6, yc - 1.1, yc + 0.5, z + 0.005, z + 0.02, M_FABRIC)
    span("low_table", -0.5, 0.7, yc - 0.6, yc, z + 0.28, z + 0.32, M_OAK)
    for sx, sy in ((-0.45, -0.56), (0.65, -0.56), (-0.45, -0.04), (0.65, -0.04)): span(f"lt_leg{sx}{sy}", sx - 0.02, sx + 0.02, yc + sy - 0.02, yc + sy + 0.02, z, z + 0.28, M_OAK)
    for i in range(2): cyl(f"cup{i}", (-0.1 + i * 0.35, yc - 0.3, z + 0.36), 0.04, 0.07, M_PAPER, verts=14)
    # 本棚（西の壁）
    for i in range(5):
        zz = z + 0.3 + i * 0.38
        span(f"shelf{i}", -WI / 2 + 0.2, -WI / 2 + 0.5, L["y0"] - 2.2, L["y0"] - 0.4, zz, zz + 0.03, M_OAK)
        yy = L["y0"] - 2.15
        while yy < L["y0"] - 0.5:
            t = rng.uniform(0.02, 0.05); hh = rng.uniform(0.18, 0.3)
            span(f"book{i}_{yy:.2f}", -WI / 2 + 0.22, -WI / 2 + 0.22 + rng.uniform(0.14, 0.24), yy, yy + t, zz + 0.03, zz + 0.03 + hh, BOOKS[int(rng.integers(0, 10))])
            yy += t + 0.005
    cyl("lamp_l", (1.0, yc - 0.3, z + 0.27), 0.16, 0.44, M_PAPER, verts=24); span("lamp_l_core", 0.95, 1.05, yc - 0.35, yc - 0.25, z + 0.15, z + 0.4, M_LAMP)
    # 段 3: 寝床（低い台と布団）
    L = LV[3]; z = L["z"]; yc = (L["y0"] + L["y1"]) / 2
    span("bed", -WI / 2 + 0.2, -WI / 2 + 2.2, yc - 1.2, yc + 1.2, z, z + 0.25, M_OAK)
    span("futon", -WI / 2 + 0.3, -WI / 2 + 2.1, yc - 1.1, yc + 1.1, z + 0.25, z + 0.45, M_LINEN)
    span("pillow", -WI / 2 + 0.4, -WI / 2 + 1.0, yc + 0.5, yc + 1.0, z + 0.45, z + 0.55, M_LINEN)
    cyl("lamp_b", (WI / 2 - 0.4, yc, z + 0.2), 0.13, 0.32, M_PAPER, verts=24); span("lamp_b_core", WI / 2 - 0.45, WI / 2 - 0.35, yc - 0.05, yc + 0.05, z + 0.1, z + 0.3, M_LAMP)
    # 段 4: 石の浴槽と水
    L = LV[4]; z = L["z"]; yc = (L["y0"] + L["y1"]) / 2
    span("tub", -WI / 2 + 0.3, -WI / 2 + 2.3, yc - 1.0, yc + 1.0, z, z + 0.55, M_STONE)
    c = span("tub_cut", -WI / 2 + 0.45, -WI / 2 + 2.15, yc - 0.85, yc + 0.85, z + 0.1, z + 0.7, M_IRON); cut(bpy.data.objects["tub"], c)
    span("tub_water", -WI / 2 + 0.45, -WI / 2 + 2.15, yc - 0.85, yc + 0.85, z + 0.1, z + 0.48, M_WATER)
    cyl("lamp_w", (1.7, yc + 1.6, z + 0.22), 0.13, 0.36, M_PAPER, verts=24); span("lamp_w_core", 1.65, 1.75, yc + 1.55, yc + 1.65, z + 0.1, z + 0.35, M_LAMP)

def person(name, x, y, z, yaw, h=1.68):
    """影として読める人。頭は球、胴は上がやや広い柱、脚は 2 本"""
    r = np.random.default_rng(abs(hash(name)) % 1000); B = Batch()
    B._ph = r.uniform(0, 100, 3)
    B.blob((x, y, z + h * 0.92), h * 0.065, r, 0.05)
    B.cone((x, y, z + h * 0.66), h * 0.075, h * 0.115, h * 0.36, 10)   # 胴（肩に向けて広がる）
    for sx in (-1, 1):
        B.cone((x + math.cos(yaw) * sx * h * 0.055, y + math.sin(yaw) * sx * h * 0.055, z + h * 0.24), h * 0.03, h * 0.045, h * 0.48, 7)
        B.cone((x + math.cos(yaw) * sx * h * 0.135, y + math.sin(yaw) * sx * h * 0.135, z + h * 0.62), h * 0.02, h * 0.03, h * 0.32, 7)
    o = B.obj(name, M_BLACK, smooth=True); return o
def room_lights(watt):
    for o in [o for o in bpy.data.objects if o.name.startswith("rl_")]: bpy.data.objects.remove(o, do_unlink=True)
    if watt <= 0: return
    for k, L in enumerate(LV):
        ld = bpy.data.lights.new(f"rl_{k}", 'POINT'); ld.energy = watt * (1.6 if k == 2 else 1.0); ld.color = (1.0, 0.72, 0.45); ld.shadow_soft_size = 0.4
        lo = bpy.data.objects.new(f"rl_{k}", ld); lo.location = (0.0, (L["y0"] + L["y1"]) / 2, L["z"] + min(L["h"] - 0.5, 2.0)); lo.visible_camera = False; lo.visible_transmission = False; lo.visible_glossy = False; bpy.context.collection.objects.link(lo)
    ld = bpy.data.lights.new("rl_tower", 'POINT'); ld.energy = watt * 0.5; ld.color = (1.0, 0.72, 0.45)
    lo = bpy.data.objects.new("rl_tower", ld); lo.location = (1.55, LV[2]["y0"] - 3.0, LV[0]["h"] + 0.3 + 3.2 + 1.4); lo.visible_camera = False; lo.visible_transmission = False; lo.visible_glossy = False; bpy.context.collection.objects.link(lo)

# ============ 組む ============
_t = time.time()
def _lap(nm):
    global _t; print("PHASE", nm, round(time.time() - _t, 1), "s", flush=True); _t = time.time()
terrain(); _lap("terrain"); house(); _lap("house"); tower(); _lap("tower"); furniture(); _lap("furniture"); forest(); _lap("forest")
# 直方体は角を少し丸める（光が角で拾えて、模型っぽさが減る）
for o in list(bpy.data.objects):
    if o.type != 'MESH' or o.name.startswith(("terrain", "cedar_c", "tree_c", "stream", "road")): continue
    dims = o.dimensions; w = min(0.01, min(dims) * 0.3)
    if w < 0.002: continue
    m = o.modifiers.new("bev", 'BEVEL'); m.width = w; m.segments = 2; m.limit_method = 'ANGLE'

# ============ 空と光 ============
SUN_W = 6.0
def sky(elev_deg, rot_deg, strength=0.06, dust=1.5, clouds=False):
    world = bpy.data.worlds.new("sky"); scene.world = world; world.use_nodes = True; nt = world.node_tree
    for n in list(nt.nodes): nt.nodes.remove(n)
    s = nt.nodes.new("ShaderNodeTexSky")
    try: s.sky_type = 'NISHITA'
    except Exception: pass
    s.sun_elevation = math.radians(elev_deg); s.sun_rotation = math.radians(rot_deg)
    try: s.sun_disc = False; s.dust_density = dust; s.altitude = 400
    except Exception: pass
    # 太陽ランプ: 方位 az は 0=北(+y)から時計回り、仰角 elev
    for o in [o for o in bpy.data.objects if o.name == "sun"]: bpy.data.objects.remove(o, do_unlink=True)
    ld = bpy.data.lights.new("sun", 'SUN'); ld.energy = SUN_W; ld.angle = math.radians(1.0)
    ld.color = (1.0, 0.86, 0.70) if elev_deg < 14 else (1.0, 0.95, 0.88)
    lo = bpy.data.objects.new("sun", ld); bpy.context.collection.objects.link(lo)
    az = math.radians(rot_deg); el = math.radians(elev_deg)
    src = mathutils.Vector((math.sin(az) * math.cos(el), math.cos(az) * math.cos(el), math.sin(el)))   # 太陽のある向き
    lo.rotation_euler = (-src).to_track_quat('-Z', 'Y').to_euler()
    b = nt.nodes.new("ShaderNodeBackground"); b.inputs["Strength"].default_value = strength
    o = nt.nodes.new("ShaderNodeOutputWorld"); nt.links.new(b.outputs[0], o.inputs[0])
    if clouds:
        # 方向ベクトルを平面に投影してノイズ → 高い雲。太陽に近い側ほど暖色
        co = nt.nodes.new("ShaderNodeTexCoord"); sp = nt.nodes.new("ShaderNodeSeparateXYZ"); nt.links.new(co.outputs["Generated"], sp.inputs[0])
        zc = nt.nodes.new("ShaderNodeMath"); zc.operation = 'MAXIMUM'; zc.inputs[1].default_value = 0.03; nt.links.new(sp.outputs["Z"], zc.inputs[0])
        px = nt.nodes.new("ShaderNodeMath"); px.operation = 'DIVIDE'; nt.links.new(sp.outputs["X"], px.inputs[0]); nt.links.new(zc.outputs[0], px.inputs[1])
        py = nt.nodes.new("ShaderNodeMath"); py.operation = 'DIVIDE'; nt.links.new(sp.outputs["Y"], py.inputs[0]); nt.links.new(zc.outputs[0], py.inputs[1])
        cb = nt.nodes.new("ShaderNodeCombineXYZ"); nt.links.new(px.outputs[0], cb.inputs[0]); nt.links.new(py.outputs[0], cb.inputs[1])
        nz = nt.nodes.new("ShaderNodeTexNoise"); nz.inputs["Scale"].default_value = 0.22; nz.inputs["Detail"].default_value = 9; nz.inputs["Roughness"].default_value = 0.66
        nt.links.new(cb.outputs[0], nz.inputs["Vector"])
        rp = nt.nodes.new("ShaderNodeValToRGB"); rp.color_ramp.elements[0].position = 0.40; rp.color_ramp.elements[1].position = 0.60; nt.links.new(nz.outputs["Fac"], rp.inputs[0])
        # 地平線近くで薄く
        hz = nt.nodes.new("ShaderNodeMath"); hz.operation = 'MULTIPLY'; hz.inputs[1].default_value = 6.0; nt.links.new(sp.outputs["Z"], hz.inputs[0])
        hc = nt.nodes.new("ShaderNodeClamp"); nt.links.new(hz.outputs[0], hc.inputs[0])
        mk = nt.nodes.new("ShaderNodeMath"); mk.operation = 'MULTIPLY'; nt.links.new(rp.outputs[0], mk.inputs[0]); nt.links.new(hc.outputs[0], mk.inputs[1])
        # 太陽との内積で色
        dt = nt.nodes.new("ShaderNodeVectorMath"); dt.operation = 'DOT_PRODUCT'; dt.inputs[1].default_value = tuple(src); nt.links.new(co.outputs["Generated"], dt.inputs[0])
        dp = nt.nodes.new("ShaderNodeMath"); dp.operation = 'MULTIPLY_ADD'; dp.inputs[1].default_value = 0.5; dp.inputs[2].default_value = 0.5; nt.links.new(dt.outputs["Value"], dp.inputs[0])
        dq = nt.nodes.new("ShaderNodeMath"); dq.operation = 'POWER'; dq.inputs[1].default_value = 2.5; nt.links.new(dp.outputs[0], dq.inputs[0])
        cc = nt.nodes.new("ShaderNodeMix"); cc.data_type = 'RGBA'; cc.inputs[6].default_value = (0.42, 0.36, 0.46, 1); cc.inputs[7].default_value = (2.4, 0.9, 0.5, 1)
        nt.links.new(dq.outputs[0], cc.inputs[0])
        fin = nt.nodes.new("ShaderNodeMix"); fin.data_type = 'RGBA'; nt.links.new(mk.outputs[0], fin.inputs[0]); nt.links.new(s.outputs[0], fin.inputs[6]); nt.links.new(cc.outputs[2], fin.inputs[7])
        nt.links.new(fin.outputs[2], b.inputs["Color"])
    else:
        nt.links.new(s.outputs[0], b.inputs["Color"])

scene.render.engine = 'CYCLES'; scene.cycles.device = 'GPU'
prefs = bpy.context.preferences.addons["cycles"].preferences
prefs.compute_device_type = 'CUDA'; prefs.get_devices()
for d in prefs.devices: d.use = (d.type == 'CUDA')
scene.cycles.samples = SAMPLES; scene.cycles.use_denoising = True
scene.cycles.max_bounces = 10; scene.cycles.transmission_bounces = 10; scene.cycles.transparent_max_bounces = 16
scene.render.resolution_x, scene.render.resolution_y = RES
scene.view_settings.view_transform = 'AgX'; scene.view_settings.look = 'AgX - Medium High Contrast'
scene.render.film_transparent = True
vl = bpy.context.view_layer; vl.use_pass_mist = True; vl.use_pass_environment = True
scene.world = scene.world or bpy.data.worlds.new("w")
def setup_mist(fog_rgb, start, depth, amount):
    if hasattr(scene, "compositing_node_group"):   # Blender 5: 合成ノードはノードグループ
        nt = bpy.data.node_groups.get("comp") or bpy.data.node_groups.new("comp", 'CompositorNodeTree'); scene.compositing_node_group = nt
    else:
        scene.use_nodes = True; nt = scene.node_tree
    for n in list(nt.nodes): nt.nodes.remove(n)
    rl = nt.nodes.new("CompositorNodeRLayers")
    try:
        comp = nt.nodes.new("CompositorNodeComposite")
    except RuntimeError:
        nt.interface.new_socket("Image", in_out='OUTPUT', socket_type='NodeSocketColor'); comp = nt.nodes.new("NodeGroupOutput")
    try:
        mixn = nt.nodes.new("CompositorNodeMixRGB"); mixn.blend_type = 'MIX'; mixn.inputs[2].default_value = (*fog_rgb, 1)
        fac, inA, outC = mixn.inputs[0], mixn.inputs[1], mixn.outputs[0]
        mm = nt.nodes.new("CompositorNodeMath")
    except RuntimeError:
        mixn = nt.nodes.new("ShaderNodeMix"); mixn.data_type = 'RGBA'; mixn.inputs[7].default_value = (*fog_rgb, 1)
        fac, inA, outC = mixn.inputs[0], mixn.inputs[6], mixn.outputs[2]
        mm = nt.nodes.new("ShaderNodeMath")
    mm.operation = 'MULTIPLY'; mm.inputs[1].default_value = amount
    nt.links.new(rl.outputs["Mist"], mm.inputs[0])
    # 空（アルファ 0）には霧をかけない
    try: ma = nt.nodes.new("CompositorNodeMath")
    except RuntimeError: ma = nt.nodes.new("ShaderNodeMath")
    ma.operation = 'MULTIPLY'; nt.links.new(mm.outputs[0], ma.inputs[0]); nt.links.new(rl.outputs["Alpha"], ma.inputs[1]); nt.links.new(ma.outputs[0], fac)
    nt.links.new(rl.outputs["Image"], inA)
    # 地物を環境（空）の上に重ねる
    env = next(o_ for o_ in rl.outputs if o_.name in ("Env", "Environment"))
    ao = nt.nodes.new("ShaderNodeMix"); ao.data_type = 'RGBA'
    nt.links.new(rl.outputs["Alpha"], ao.inputs[0]); nt.links.new(env, ao.inputs[6]); nt.links.new(outC, ao.inputs[7])
    nt.links.new(ao.outputs[2], comp.inputs[0])
    scene.world.mist_settings.start = start; scene.world.mist_settings.depth = depth; scene.world.mist_settings.falloff = 'QUADRATIC'

def cam(name, loc, tgt, lens=24, shift_y=0.0):
    bpy.ops.object.camera_add(location=loc); c = bpy.context.active_object; c.name = name
    d = mathutils.Vector(tgt) - mathutils.Vector(loc); c.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
    c.data.lens = lens; c.data.shift_y = shift_y; c.data.clip_end = 400; return c

L2 = LV[2]; L4 = LV[4]
# 画: 名前 → (カメラ位置, 注視点, レンズ, 太陽高度, 太陽方位（0=北から時計回り…Blender の sun_rotation は +z 回り）, 強さ, 露出)
PICS = {
    # 外観・夕方: 谷川の対岸から見上げる。低い西日が段々の屋根と焼杉をなめる
    "exterior": ((5.0, Y_STREAM - 7.0, ground_z(5.0, Y_STREAM - 7.0) + 1.7), (-0.5, -19.0, ground_z(5.0, Y_STREAM - 7.0) + 1.7 + 2.3), 26, 3.0, 300.0, 0.14, 1.0, 320.0),
    # 平面（屋根と天井を外して真上から。壁の上端が見える模型写真の形）
    "plan": ((0.0, Y_END / 2 + 1.0, 60.0), (0.0, Y_END / 2 + 1.0, -4.0), 50, 55.0, 160.0, 0.6, 0.9, 40.0),
    # 内観・朝: 玄関土間から下を見通す（主画）。東の低い朝日が光の段を抜ける
    "enfilade": ((-1.3, LV[1]["y1"] + 0.3, LV[1]["z"] + 1.45), (-0.6, -26.0, -4.3), 21, 24.0, 120.0, 0.5, 1.4, 0.0),
    # 居間: 南西の隅からストーブと塔
    "living": ((-2.2, L2["y1"] + 0.6, L2["z"] + 1.45), (1.2, L2["y0"] - 1.0, L2["z"] + 1.9), 20, 24.0, 115.0, 0.5, 1.2, 0.0),
    # 水の間: 北東の隅から浴槽と谷川
    "water": ((2.0, L4["y0"] - 0.6, L4["z"] + 1.4), (-1.2, L4["y1"] - 4.0, L4["z"] + 0.6), 20, 30.0, 150.0, 0.5, 0.65, 0.0),
    # 寝室: 低い天井、寝床の壁の小さな窓、朝の光
    "bed": ((2.3, LV[3]["y0"] - 0.3, LV[3]["z"] + 0.95), (-1.0, LV[3]["y1"] - 0.6, LV[3]["z"] + 1.25), 20, 22.0, 110.0, 0.5, 1.6, 0.0),
    # 見張り台から南を見下ろす
    "lookout": ((1.55, L2["y0"] - 3.0 + 0.5, LV[0]["h"] + 0.3 + 3.2 + 1.5), (0.2, Y_END - 6.0, -7.5), 19, 20.0, 110.0, 0.4, 0.6, 0.0),
    # 谷川の中から: 水面に灯りが映る
    "stream": ((-4.4, POND["y0"] + 0.3, POND["z"] + 0.7), (1.6, -23.0, POND["z"] + 5.6), 18, 3.0, 300.0, 0.14, 1.0, 320.0),
    "pondcheck": ((14.0, Y_STREAM - 4.0, POND["z"] + 9.0), (0.0, Y_END - 4.0, POND["z"]), 28, 30.0, 200.0, 0.5, 0.5, 100.0),
    # 屋根の段々: 南東の上空から夕方。五枚の屋根と光の段、塔、谷川
    "roofs": ((21.0, Y_END - 14.0, 13.0), (-1.0, -15.0, -2.0), 32, 3.0, 300.0, 0.14, 1.0, 320.0),
    # 上空から（配置の確認用）
    "aerial": ((34.0, -4.0, 26.0), (0.0, -17.0, -3.0), 30, 40.0, 200.0, 0.4, 0.3, 0.0),
    # 断面（東から直交投影。道が一本であることを見せる）
    "section": ((60.0, -17.0, 1.5), (0.0, -17.0, 1.5), 50, 38.0, 150.0, 0.6, 1.0, 60.0),
}
person("person_water", -1.2, L4["y1"] - 0.9, L4["z"], math.radians(90))
print("POND", POND, "Y_END", Y_END, "Y_STREAM", Y_STREAM, "zw", ground_z(0, Y_STREAM) + 0.88, "ground@pond_n", ground_z(0, POND["y1"]), "ground@pond_s", ground_z(0, POND["y0"]), "terrace_z", LV[4]["z"] - 0.14, flush=True)
names = [v for v in VIEWS if v != "none"] if VIEWS else ["exterior", "enfilade"]
print("OBJECTS", len(bpy.data.objects), flush=True)
for nm in names:
    loc, tgt, lens, elev, rot, strength, expo, lamps = PICS[nm]
    sky(elev, rot, strength, dust=3.5 if nm == "exterior" else 1.5, clouds=(nm in ("exterior", "roofs", "stream"))); scene.view_settings.exposure = expo; room_lights(lamps)
    fog = (0.55, 0.52, 0.58) if nm in ("exterior", "roofs", "stream") else (0.62, 0.66, 0.74) if nm in ("aerial", "lookout") else (0.5, 0.52, 0.56)
    setup_mist(fog, 14.0, 80.0, {"exterior": 0.38, "roofs": 0.35, "stream": 0.3, "aerial": 0.4, "lookout": 0.32, "section": 0.15, "water": 0.12}.get(nm, 0.25))
    scene.camera = cam("cam_" + nm, loc, tgt, lens)
    for o in bpy.data.objects:
        if o.name.startswith(("roof", "ceil", "lookout", "band", "skylight", "tower_skin", "tower_roof")): o.hide_render = (nm == "plan")
    scene.camera.data.clip_start = 0.1
    if nm == "plan":
        scene.camera.data.type = 'ORTHO'; scene.camera.data.ortho_scale = 40.0; scene.camera.data.clip_start = 60.0 - 1.6   # z = 1.6 で切る（玄関の壁の上端より上）
        scene.camera.rotation_euler = (0, 0, 0); scene.render.resolution_x, scene.render.resolution_y = int(RES[0] * 0.6), RES[0]
    elif nm == "section":
        # 直交投影、x = 0.8 の面で切る（塔の中心 x≈0.8 を通す）。切り口は材質の裏面＝黒
        scene.camera.data.type = 'ORTHO'; scene.camera.data.ortho_scale = 52.0; scene.camera.data.clip_start = loc[0] - 0.8
        scene.render.resolution_x, scene.render.resolution_y = RES[0], int(RES[0] * 0.42)
    else:
        scene.render.resolution_x, scene.render.resolution_y = RES
    scene.render.filepath = os.path.join(OUT, f"{nm}.png")
    if os.environ.get("RAYCHECK"):
        # 画面上の数点からレイを飛ばし、何に当たるかを出す（見えているものの正体を調べる）
        import bpy_extras
        dg = bpy.context.evaluated_depsgraph_get(); cam_o = scene.camera
        for (u, v) in [(0.5, 0.2), (0.5, 0.35), (0.3, 0.3), (0.5, 0.5), (0.8, 0.3)]:
            frame = [cam_o.matrix_world @ f for f in cam_o.data.view_frame(scene=scene)]
            tr, tl, bl, br = frame[0], frame[1], frame[2], frame[3]
            pt = bl + (br - bl) * u + (tl - bl) * v; d = (pt - cam_o.matrix_world.translation).normalized()
            hit, loc, nrm, idx, obj, mat = scene.ray_cast(dg, cam_o.matrix_world.translation, d)
            print("RAY", u, v, hit, obj.name if obj else None, tuple(round(c, 2) for c in loc) if hit else None, flush=True)
        continue
    t0 = time.time(); bpy.ops.render.render(write_still=True); print("RENDERED", nm, round(time.time() - t0), "s", flush=True)
