この記事について

スポンサーリンク

Geometry Nodes で街を作るシリーズの3本目です。1本目の プロシージャルな街を自動生成してみた で「ルールで街を生やせる」ところまで、2本目の 実践編 で道路・起伏・建物4種・中心ほど高くする作りまで足しました。

ただ、p973 の街を道路の高さから眺めると、どうしても「同じ灰色の箱が並んでいる」ように見えます。そこで今回は、建物にバリエーションをつけて、この見え方から抜け出すことを目指しました。足したのは次の5つと、住宅街・商店街・工業地帯に分ける作りです 😊

  1. 正面を近い道路へ向け、外壁の色を6色から選ぶ
  2. 高さを「階数 × 1階ぶん」で決める
  3. 屋根を3種類(平・切妻・段違い)にする
  4. 窓を、形を増やさずに質感(マテリアル)で描く
  5. 隣の区画とくっつけて、横に長い建物にする
  6. (ここから応用)住宅街・商店街・工業地帯に分け、住宅は敷地ごとに作る
道路の上から見た街。左は前作p973と同じ作りで、灰色の高い箱が並ぶ。右はこの記事の最終形で、切妻屋根の低い家が道路に沿って並び、窓と柵のある敷地が続く

要素を1つ足すたびに、同じ視点の画像と「隣の建物とそっくりな割合」、そして重さ(スライダーを動かしたときの待ち時間と、視点を回したときの1コマの時間)を記録しました。章の構成は、下準備が1つ、要素を足す段階が5つ、用途地域が1つ、重さの比較が2つです。

やってみて分かったことを先に3つだけ書いておきます。

  • 見た目のばらつきに一番効いたのは「向きと色」でした。隣と同じ形・同じ色になる割合が 24.7% から 4.8% に下がりました。一方、本物らしくしたはずの「階数」は、そっくりな建物をかえって増やしました
  • 乱数を何から作るかで、街全体が入れ替わることがあります。p973 と同じ作り方のまま建物を9%合体させると、残った建物の89%の形が変わってしまいました。前回の記事の道路の線がずれていたのも、同じ原因でした
  • 重さは、建物の数より「分身」の数で決まっていました。屋根を別の部品にすると分身が2割増え、描画も重くなります。一方、型を4種から64種に増やしても重さは変わりませんでした

用語集

言葉何を指すのか
Geometry NodesBlender で、ノード(箱)を線でつないで形を作る仕組み。数値を変えると、街全体がその場で作り直される
分身(インスタンス)同じ形を、位置・向き・大きさだけ変えて何度も置いたもの。形のデータは1つで済むので軽い。ノード「Instance on Points」で作る
属性(アトリビュート)点や分身1つずつに持たせられる値。「この建物は何階か」「何色か」などを名前を付けて記録できる(ノード「Store Named Attribute」)。マテリアルからも読める
Index(通し番号)点に 0, 1, 2… と付く番号。点を消すと詰め直される。この記事の大事なつまずきどころ
実体化(Realize Instances)分身をやめて、すべてを1つの大きな形にまとめること
ソリッド表示/マテリアル表示3Dビューの表示の切り替え。ソリッドは灰色の形だけ、マテリアルは色や質感まで描く(EEVEE で描く)
gridこの記事での街の大きさ。grid=161 は1辺161区画(1区画2m・320m四方)。81・161・241 の3つで測った

【下調べ】「同じ形ばかり」を数で測る

「同じ形ばかりに見える」を、まず数字にしました。上下左右に隣り合う建物の組を全部数え、そのうち似ている組の割合を出します。似ているの基準は2段階です。

  • 同じ形と色:種類(箱・段違いなど4種)、屋根、外壁の色、合体しているかどうかが同じ。向きは、向きで見た目が変わる建物(屋上設備のある型・切妻屋根)のときだけ比べる。高さは問わない
  • そっくり:上の条件に加えて、高さの差が10%以内

p973 と同じ作りの街(grid=161・12,960棟)で数えると、同じ形と色は 24.7%、そっくりは 3.6%でした。そっくりが少ないのは、p973 が高さを連続した値でばらつかせているからです。それでも街路から見ると同じ箱が並んで見えます。高さが少し違うだけでは、目には「同じ種類の灰色の箱」にしか見えないということです。この記事では、両方の数字を追いかけます。

数えるのは Blender の外ではなく、評価したあとの街から分身の属性を読み出して行いました(run_stages.py の similarity())。

【下準備】乱数は「区画の番号」から作る

5つの要素を足す前に、乱数の作り方を変えました。これをしないと、5番目の「合体」を足したときに街全体が入れ替わってしまいます。

p973 では、建物の種類や高さを 点の通し番号(Index) から作っていました。sin(Index × 12.9898) × 43758.547 の小数部という、シェーダーでよく使う作り方です。ところが Index は、点を消すと詰め直されます。

建物を1つ消すと、後ろの通し番号(Index)が全部ずれる合体の前AIndex 0BIndex 1CIndex 2DIndex 3EIndex 4FIndex 5合体の後AIndex 0B合体で消えたCIndex 1 ←DIndex 2 ←EIndex 3 ←FIndex 4 ←Index から乱数を作ると、C 以降は「別の建物の乱数」を引き継ぐ → 種類も高さも入れ替わる区画の列・行(gx, gy)を最初に記録して乱数の ID にすると、消しても足しても変わらない

実際に、Index から乱数を作る版で「合体」を足すと、合体で消えた 1,175棟(9.1%)とは関係のない建物まで変わりました。残った単独の建物 10,590棟のうち 9,454棟(89.3%)で、種類・高さ・屋根のどれかが変わったのです(grid=161)。区画の番号から作る版では、変わった建物は 0棟でした。

直し方は、地面のグリッドを点にした直後に、その点が何列目・何行目かを属性として記録しておくことです。

ノードエディタの枠「② 区画の点に列・行の番号を記録」。Mesh to Points、Position、Separate XYZ、Add、Divide、Round を通して、Store Named Attribute で gx と gy を記録している

画面では、Position の X と Y に街の半分の大きさを足して、1区画の大きさ(2m)で割って丸め、Store Named Attribute で gx と gy という名前で点に書き込んでいます(この画面は grid=41 なので、足している値は 40)。以降は Named Attribute ノードでこの値を読み、gy × N + gx を区画の番号にして、Random Value ノードの ID に渡します。Seed を変えれば、種類用・高さ用・色用と別々の乱数が取れます。

# city_var.py より(区画の番号から乱数を作る)
def _rand(ng, id_src, seed, loc, parent=None, label=None):
    n = _new_node(ng, "FunctionNodeRandomValue", label or f"乱数(seed {seed})", loc, parent)
    n.data_type = "FLOAT"
    ng.links.new(_sock(id_src[0], id_src[1], "outputs"), _sock(n, "ID"))   # ID=区画の番号
    _sock(n, "Seed").default_value = seed
    return (n, "Value")

記録した属性は、点を消しても、位置をずらしても、分身にしても付いて回ります。あとで「隣とそっくり」を数えるときにも、この gx・gy で隣を探しました。

前回の記事(p973)の不具合
p973 の道路の中央線も、同じ原因でずれていました。中央線を縦に引くか横に引くか、交差点かどうかを、「道路の点だけ残す」で点を消したあとの Index から判定していたため、縦の道路に横向きの線が混ざり、交差点にも線が引かれていました。さらに2区画幅の道路に区画ごとに線を置いていたので、1本の道路に線が2本ありました。この記事の街では gx・gy で判定し、線は1区画目にだけ置いて半区画ずらしています。p973 の本文と画像はそのままにしてあります。

もう1つ、乱数の作り方で分かったことがあります。同じスクリプトを Mac と Windows で動かして建物を1棟ずつ突き合わせると、Random Value で作った値(種類・高さ・色・屋根・向き)はすべて一致しました。ずれたのは、p973 と同じ sin の式のまま残した空き地の判定だけで、12,960区画のうち13区画です。番号が大きいと sin の計算の細かい誤差が OS によって違い、境目の区画だけ結果が変わるためです。

再現:乱数の作り方で、街が入れ替わるか(grid=161)

Blender -b --factory-startup --python run_stages.py -- --grid 161 --dump --stages v4_windows,v5_merge,x_index_v4,x_index_v5
Blender -b --factory-startup --python compare_rows.py -- out/rows_v4_windows_n161.json out/rows_v5_merge_n161.json
Blender -b --factory-startup --python compare_rows.py -- out/rows_x_index_v4_n161.json out/rows_x_index_v5_n161.json
(区画の番号から作る版)
Aだけにある建物 1175  Bだけにある建物 0
両方にある単独の建物 10590  そのうち値が変わった 0(0.0%)
(通し番号から作る版・Mac)
Aだけにある建物 1175  Bだけにある建物 0
両方にある単独の建物 10590  そのうち値が変わった 9467(89.4%)
  変わった項目ごと: {'type': 7815, 'h': 7204, 'roof': 3668, 'col_id': 0, 'rot': 0}

x_index_v4 / x_index_v5 は、種類と高さだけを p973 と同じ通し番号の式で作る版です(city_var.py)。

if seed_by == "index":
    # p973と同じ作り方:点の通し番号(Index)から乱数を作る。
    # ★建物を1つ消すと、それより後ろの番号が全部ずれる
    idx = _new_node(ng, "GeometryNodeInputIndex", "点の通し番号", (-1250, -2560), f3)
    c = _Ctx(ng, -1080, -2560, f3)
    r_t = c.m("FRACT", c.m("MULTIPLY", c.m("SINE", c.m("MULTIPLY", (idx, "Index"), 78.233)), 43758.547))
    c = _Ctx(ng, -1080, -2720, f3)
    r_h = c.m("FRACT", c.m("MULTIPLY", c.m("SINE", c.m("MULTIPLY", (idx, "Index"), 12.9898)), 43758.547))
else:
    r_t = _rand(ng, cid, 3, (-1080, -2560), f3, "種類の乱数")
    r_h = _rand(ng, cid, 2, (-1080, -2720), f3, "高さの乱数")

通し番号の式も sin を使うので、Mac で動かすと 9,467棟(89.4%)、Windows では 9,454棟(89.3%)と少しずれます。compare_rows.py に Mac と Windows で作った同じ段階の rows を渡すと、空き地の13区画の食い違いもこの形で出ます。

このほか、地面の起伏もなだらかにしました。最初は p973 と同じ細かさの起伏(高さだけ半分)にしていましたが、これだと2〜3区画ごとに高さが変わり、区画ごとに平らな板を敷いている道路が段差になって波打って見えました。道路の上だけを測ると、平均の勾配が 11.4%、最大で 45.8% あります。ノイズの波を12.5倍に伸ばし、細かい段を減らし、高さを下げたところ、平均 0.7%・最大 2.8% になりました。地面の面の数は変わらないので、重さには影響しません。

再現:道路の勾配(grid=161)

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v5_merge --terrain old --road-slope
Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v5_merge --road-slope
[p1002] v5_merge 道路の勾配 平均 11.44% / 最大 45.84%   ← 最初の起伏(--terrain old)
[p1002] v5_merge 道路の勾配 平均 0.7% / 最大 2.78%      ← この記事の起伏

起伏の設定は build_city_tree の引数です(city_var.py)。--terrain old は TERRAIN_OLD に差し替えます。

terrain_amp=1.6, terrain_scale=0.128, terrain_detail=1.0,
TERRAIN_OLD = dict(terrain_amp=3.0, terrain_scale=1.6, terrain_detail=2.0)

道路の1区画目の中心で真上から下へ光線を飛ばして高さを拾い、隣の区画との段差を区画の大きさ(2m)で割っています(run_stages.py の road_slope())。

【段階1】正面を道路へ向け、外壁の色を6色から選ぶ

最初に足したのは「向き」と「色」です。

向きは、建物の正面(-Y 側)を、いちばん近い道路へ向けます。道路は8区画ごとに2列なので、ブロックの中の列(gx % 8)が分かれば、左右の道路までの距離が出ます。上下も同じです。4方向のうち一番近い道路の向きに、0度・90度・180度・270度のどれかで回します。形の上では、切妻屋根の棟の向き(道路と平行になる)と、屋上設備の位置(奥に寄る)に効きます。

色は、外壁を6色(青みのグレー・グレー・白っぽい・ベージュ・レンガ・こげ茶)から選びます。中心に近いほど寒色、外側ほど暖色が出やすいように、乱数に「中心からの近さ」を少し混ぜました。選んだ色の番号は属性 col_id として分身に記録し、マテリアルの Attribute ノード(種類は Instancer)で読んで、Color Ramp(補間なし)で色に変えます。

段階1。道路の上から見た街で、灰色だった建物が白・ベージュ・レンガ色などに塗り分けられている

これだけで、同じ形と色の組は 24.7% → 4.8%、そっくりは 3.6% → 0.7% に下がりました。見た目の種類(種類・屋根・色・向き・合体・階数の組み合わせ)は 187 から 1,149 に増えています。足したノードの数のわりに、いちばん効いた要素でした。

再現:段階1

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v0_p973,v1_orient
[p1002] v0_p973            bld=12960 inst=29521 twins=0.036 looks=187
[p1002] v1_orient          bld=12960 inst=29521 twins=0.0069 looks=1149

向きの要点(city_var.py)。左右・上下の道路までの距離を出し、近いほうへ回します。

dl = c.m("SUBTRACT", bx, road_width - 1.0, "左の道路まで")
dr = c.m("SUBTRACT", float(road_every), bx, "右の道路まで")
c = _Ctx(ng, -1080, -3720, fo)
dd = c.m("SUBTRACT", by, road_width - 1.0, "下の道路まで")
du = c.m("SUBTRACT", float(road_every), by, "上の道路まで")
c = _Ctx(ng, -740, -3560, fo)
dxm = c.m("MINIMUM", dl, dr)
x_neg = c.m("LESS_THAN", dl, c.m("ADD", dr, 0.01), "左が近い")
c = _Ctx(ng, -740, -3720, fo)
dym = c.m("MINIMUM", dd, du)
y_neg = c.m("LESS_THAN", dd, c.m("ADD", du, 0.01), "下が近い")
use_x = _v(_math(ng, "LESS_THAN", dxm, dym, "横の道路より縦の道路が近い", (-230, -3640), fo))
if merged:   # 合体した横長の棟は、長い辺を上下の道路へ向ける
    use_x = _v(_math(ng, "MULTIPLY", use_x, _v(_math(ng, "SUBTRACT", 1.0, merged, None, (-60, -3800), fo)),
                     None, (110, -3640), fo))
# 角度(度):-Y=0 / +X=90 / +Y=180 / -X=270
c = _Ctx(ng, -230, -3880, fo)
ang_x = c.m("ADD", 90.0, c.m("MULTIPLY", x_neg, 180.0))           # 左なら270、右なら90
ang_y = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, y_neg), 180.0)       # 下なら0、上なら180
c = _Ctx(ng, 280, -3800, fo)
ang = c.m("ADD", ang_y, c.m("MULTIPLY", use_x, c.m("SUBTRACT", ang_x, ang_y)), "向き(度)")
rot_z = c.m("RADIANS", ang)

Blender の画面で確かめるなら、同じコマンドに --save を付けて out にできる .blend を開き、Geometry Nodes タブのノードエディタで枠 ①' 正面を近い道路へ向ける を見ます。左のスプレッドシートで Instances を選ぶと、列 rot(ラジアン)と col_id(0〜5) に値が入っているのが確かめられます。

【段階2】高さを「階数 × 1階ぶん」で決める ── そっくりがかえって増えた

p973 の高さは「1〜5 の乱数 × 中心ほど大きくなる倍率(1〜4)」で、連続した値でした。これを、まず階数を決めてから「階数 × 1階ぶん(0.35)」で高さにする作りに変えました。階数は、街の端で2〜4階、中心で8〜40階です。乱数を2乗して使い、低い建物が多くなるようにしています。低層ワイド型は3階までにしました。階数は属性 floors として記録し、あとで窓を描くときに使います。

段階2。高さが階数で決まり、街路沿いは低い建物が増えて空が広く見える

本物の街に近づけたつもりでしたが、数字は逆に動きました。そっくりは 0.7% → 1.9% と、3倍近くに増えたのです。見た目の種類も 1,149 から 594 に半減しました。

理由は単純で、高さが「階数ごと」の飛び飛びの値になり、さらに端の区画は2〜4階に集まるので、隣と同じ階数になりやすくなったからです。本物らしくすることと、ばらつかせることは別でした。それでもこの段階を残したのは、窓の段を階にそろえるためと、街路から見たときの空の広がりが自然になるためです。

再現:段階2

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v1_orient,v2_floors
[p1002] v1_orient          bld=12960 inst=29521 twins=0.0069 looks=1149
[p1002] v2_floors          bld=12960 inst=29521 twins=0.0192 looks=594

階数の要点(city_var.py)。near は中心からの近さ(端0〜中心1)、r_h は高さの乱数です。

f_min = c.m("ADD", c.m("MULTIPLY", near, 6.0), 2.0, "最小階")
c = _Ctx(ng, -880, -3120, f3)
f_max = c.m("ADD", c.m("MULTIPLY", near, 36.0), 4.0, "最大階")
c = _Ctx(ng, -540, -3040, f3)
fl = c.m("MAXIMUM", c.m("ROUND", c.m("ADD", f_min, c.m("MULTIPLY", c.m("SUBTRACT", f_max, f_min),
                                                      c.m("MULTIPLY", r_h, r_h)))), 1.0, "階数")
# 低層ワイド型は3階まで
c = _Ctx(ng, 480, -3040, f3)
capped = c.m("MINIMUM", fl, 3.0)
fl = c.m("ADD", fl, c.m("MULTIPLY", is_D, c.m("SUBTRACT", capped, fl)), "階数(D型は3階まで)")
height = c.m("MULTIPLY", fl, FLOOR_H, "高さ=階数×1階ぶん")

Blender の画面で確かめるなら、同じコマンドに --save を付けて out にできる .blend を開き、Geometry Nodes タブのノードエディタで枠 ③ 建物ごとの値を決める を見ます。左のスプレッドシートで Instances を選ぶと、列 floors(階数)と h(高さ) に値が入っているのが確かめられます。

【段階3】屋根を3種類、別の部品として載せる

屋根は「平」「切妻」「段違い(屋上に小さな塔屋が乗る)」の3種類です。平は部品を置かず、本体の屋上がそのまま見えます。切妻と段違いは、本体とは別の部品(分身)として、本体の上に置きました。

  • 3階以下は切妻が出やすく(60%)、11階以上は段違いが出やすい(50%)。屋上設備のある型は平だけ
  • 置く高さは、本体の高さぶん持ち上げた位置(Set Position の Offset に高さを入れる)
  • 本体の上の段の広さは型で違うので(段違い型は 0.69 倍、低層ワイド型は 1.15 倍)、屋根の部品の幅もそれに合わせる
  • Instance on Points の Selection に「屋根が平でない」を入れ、Pick Instance で切妻か塔屋かを選ぶ
ノードエディタの枠「④ 建物を置く」。Collection Info(本体4種)を Instance on Points につなぎ、Pick Instance にチェック、Rotation と Scale に Combine XYZ をつないでいる

上は本体を置いている部分です。Collection Info で4種類の本体を取り出し(Separate Children と Reset Children にチェック)、Instance on Points の Pick Instance で種類の番号を渡しています。屋根の部品も同じつなぎ方で、取り出すコレクションが屋根2種になるだけです。

段階3。道路沿いの低い建物に青緑や素焼き色の切妻屋根が乗り、遠くのビルには塔屋が見える

12,960棟のうち切妻が 4,274棟、段違いが 1,711棟になり、分身の数は 29,521 → 35,506(+20%)に増えました。同じ形と色は 4.8% → 2.4% です。この分身の増加が、あとで測る重さに効いてきます。

再現:段階3

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v3_roofs
[p1002] v3_roofs           bld=12960 inst=35506 twins=0.0094 looks=922

屋根の種類を決める要点(city_var.py)。fl は階数です。

low = c.m("LESS_THAN", fl, 3.5, "3階以下")
high = c.m("GREATER_THAN", fl, 10.5, "11階以上")
mid = c.m("SUBTRACT", c.m("SUBTRACT", 1.0, low), high)
c = _Ctx(ng, -880, -4460, f3)
p_g = c.m("ADD", c.m("MULTIPLY", low, 0.6), c.m("MULTIPLY", mid, 0.1), "切妻の確率")
p_s = c.m("ADD", c.m("ADD", c.m("MULTIPLY", low, 0.1), c.m("MULTIPLY", mid, 0.3)),
          c.m("MULTIPLY", high, 0.5), "段違いの確率")
c = _Ctx(ng, -880, -4620, f3)
is_g = c.m("LESS_THAN", r_r, p_g)
is_s = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, is_g), c.m("LESS_THAN", r_r, c.m("ADD", p_g, p_s)))
roof_raw = c.m("ADD", is_g, c.m("MULTIPLY", is_s, 2.0))
roof_id = c.m("MULTIPLY", roof_raw, c.m("SUBTRACT", 1.0, is_C), "屋根 0平/1切妻/2段違い")

Blender の画面で確かめるなら、同じコマンドに --save を付けて out にできる .blend を開き、Geometry Nodes タブのノードエディタで枠 ⑤ 屋根の部品を載せる(平は何も置かない) を見ます。左のスプレッドシートで Instances を選ぶと、列 roof(0平・1切妻・2段違い) に値が入っているのが確かめられます。

【段階4】窓は、形を増やさずに質感で描く

窓を1つずつ形で作ると、建物の数 × 階数 × 窓の数だけ面が増えます。そこで、形は1つも増やさず、マテリアルの中で「壁のどこを窓の色で塗るか」を計算しました。

窓は「形」ではなく「どこを塗るか」で描く元の高さ 1.0元の高さ 0階数 floors=5 の建物① メッシュに「元の座標 lpos」「元の向き lnrm」を書き込んでおく② 縦:元の高さ × 階数 の小数部 → その階の中のどこか(0〜1)③ 横:面の向きで X か Y を選び、間隔 0.4 ごとの小数部④ 縦も横も「真ん中あたり」なら窓の色、それ以外は壁の色屋上・床(上下を向いた面)と屋上の設備には描かない形は1つも増えない=分身の数も面の数も同じ

ポイントは、マテリアルが「この面は建物のどこか」を知る方法です。最初は Texture Coordinate の Object を使うつもりでしたが、やめました。分身のままなら建物ごとの座標になりますが、実体化すると座標の基準が街全体に変わり、窓がずれてしまうからです。代わりに、建物の部品のメッシュそのものに「元の座標 lpos」と「元の向き lnrm」を属性として書き込んでおき、マテリアルではそれを読みます。こうすると、分身でも実体化しても同じ窓になります(実験3の画像で確かめています)。

# city_var.py より:部品のメッシュに、元の座標と元の向きを書き込む
a = me.attributes.new("lpos", "FLOAT_VECTOR", "POINT")
a.data.foreach_set("vector", [c for vv in me.vertices for c in vv.co])
b = me.attributes.new("lnrm", "FLOAT_VECTOR", "FACE")
b.data.foreach_set("vector", [c for p in me.polygons for c in p.normal])

マテリアル側の計算は、Math ノードだけで組めます。

  • 縦:元の高さ(lpos の Z、0〜1)× 階数(floors)の小数部が 0.30〜0.80 なら窓の段
  • 横:面が X を向いていれば Y 座標、Y を向いていれば X 座標を使い、0.4 ごとの小数部が 0.20〜0.80 なら窓の列。合体で横に伸ばした建物は、伸ばした倍率(sx)を掛けて実寸に戻す
  • 12階以上は窓を大きく(カーテンウォール風)。屋上・床・屋上設備には描かない
  • 窓の部分は色を濃いガラス色にし、表面を滑らかにする(Roughness 0.6 → 0.15)

属性は、分身のまま読むときは Attribute ノードの種類を Instancer、実体化したあとに読むときは Geometry にする必要があります。両方の Attribute ノードを置いて足し合わせておけば、無い側は 0 を返すので、どちらの状態でも同じマテリアルで済みます。

段階4。建物の壁に階ごとの窓が並び、高いビルは窓が大きい 段階4の寄り。中心の高いビル群に窓の格子が乗り、低い建物には切妻屋根が見える

形は増えていないので、分身の数も面の数も段階3と同じです。1棟ずつの値を書き出したファイルも、段階3と段階4でバイト単位まで一致しました。街路から見た印象は大きく変わったのに、データ上は何も増えていません。

再現:段階4

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v4_windows --render --views street,detail
[p1002] v4_windows         bld=12960 inst=35506 twins=0.0094 looks=922

窓の格子の要点(city_var.py の build_building_material)。M() は Math ノードを1つ作って返す小道具です。

fl = M("MULTIPLY", sp.outputs["Z"], floors, -1500, -100)
fv = M("FRACT", fl, None, -1330, -100)
# 横:X向きの面ならY座標、Y向きの面ならX座標(横に伸ばした棟は倍率をかけて実寸に戻す)
nx = M("ABSOLUTE", sn.outputs["X"], None, -1500, -500)
use_y = M("GREATER_THAN", nx, 0.5, -1330, -500)
ux = M("MULTIPLY", sp.outputs["X"], sx, -1500, -300)
u_diff = M("SUBTRACT", sp.outputs["Y"], ux, -1330, -300)
# u = ux + (Y座標 - ux) × use_y(Y向きの面なら ux、X向きの面なら Y座標)
t = M("MULTIPLY", u_diff, use_y, -1160, -300)
u = M("ADD", ux, t, -990, -300)
fu = M("FRACT", M("DIVIDE", u, WIN_PITCH, -820, -300), None, -650, -300)

# 高い建物(12階以上)は窓を大きく=カーテンウォール風
big = M("GREATER_THAN", floors, 11.5, -1500, -900)
v_lo = M("SUBTRACT", 0.30, M("MULTIPLY", big, 0.18, -1330, -900), -1160, -900)
v_hi = M("ADD", 0.80, M("MULTIPLY", big, 0.12, -1330, -1000), -1160, -1000)
u_lo = M("SUBTRACT", 0.20, M("MULTIPLY", big, 0.14, -1330, -1100), -1160, -1100)
u_hi = M("ADD", 0.80, M("MULTIPLY", big, 0.14, -1330, -1200), -1160, -1200)

m_v = M("MULTIPLY", M("GREATER_THAN", fv, v_lo, -480, -100), M("LESS_THAN", fv, v_hi, -480, -200), -310, -150)
m_u = M("MULTIPLY", M("GREATER_THAN", fu, u_lo, -480, -300), M("LESS_THAN", fu, u_hi, -480, -400), -310, -350)
nz = M("ABSOLUTE", sn.outputs["Z"], None, -1500, -700)
side = M("LESS_THAN", nz, 0.5, -1330, -700)                     # 屋上・床には描かない
in_body = M("LESS_THAN", sp.outputs["Z"], 0.999, -1330, -800)   # 屋上設備には描かない
has_f = M("GREATER_THAN", floors, 0.5, -1160, -800)            # 階数が無い分身には描かない
mask = M("MULTIPLY", M("MULTIPLY", m_v, m_u, -140, -250),
         M("MULTIPLY", M("MULTIPLY", side, in_body, -310, -700), has_f, -140, -700), 30, -400)

画面では、上のタブ Shading で建物を選び、マテリアル Building のノードを見ます。--render を付けると out/shots に街路と寄りの画像が出ます。

【段階5】隣の区画とくっつけて、横に長い建物にする

最後の要素は、隣り合う2区画を1棟にまとめる「合体」です。ここで気をつけたのは、合体した建物が道路にはみ出さないことです。

8区画ごとの1ブロック:道路2列+建物6列=ちょうど3組道路列0道路列1左列2右列3左列4右列5左列6右列7道路列0道路列1(2,3)(4,5)(6,7) の組は道路をまたがない。左(偶数列)を残して右を消し、左を半区画ずらして横に2.25倍に伸ばす

道路が8区画ごとに2列なので、ブロックの中の建物の列は 2〜7 の6列で、(2,3)(4,5)(6,7) の3組にきれいに分かれます。組ごとに1つ乱数を引き(ID は「行 × N + 列÷2 の切り捨て」)、当たった組は、左(偶数列)を残して右の点を消し、左の点を半区画ずらして、横に 2.25 倍に伸ばします。合体する確率は 15% に、中心に近いほど最大 25% 足しました。合体した建物は、長い辺を上下の道路に向けます。

段階5。道路沿いに、2区画ぶんの横に長い建物がところどころ混ざる

12,960棟が 11,785棟(-9.1%)になり、そのうち 1,195棟が合体した建物です。同じ形と色は 1.9%、そっくりは 0.7%、見た目の種類は 1,215 で、5つの要素の中では最も「ばらけた」街になりました。下は、6段階を同じ視点で並べたものです。

6段階を道路の上から同じ視点で並べた一覧。v0は灰色の箱、v1で色、v2で高さが下がり、v3で屋根、v4で窓、v5で横長の建物が加わる

再現:段階5

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v5_merge
[p1002] v5_merge           bld=11785 inst=33804 twins=0.0071 looks=1215

合体の要点(city_var.py)。gx・gy は記録した列・行、near は中心からの近さです。

is_left = c.m("LESS_THAN", c.m("FLOORED_MODULO", gx, 2.0), 0.5, "偶数列=左")
c = _Ctx(ng, -1080, -2120, fm)
pair = c.m("ADD", c.m("MULTIPLY", gy, grid_n), c.m("FLOOR", c.m("DIVIDE", gx, 2.0)), "組の番号")
r_m = _rand(ng, pair, 5, (-400, -2120), fm, "合体の乱数(組ごと)")
c = _Ctx(ng, -230, -2040, fm)
p_m = c.m("ADD", c.m("MULTIPLY", near, merge_center), merge_base, "合体する確率")
hit = c.m("LESS_THAN", r_m, p_m)
c = _Ctx(ng, 280, -1960, fm)
merged = c.m("MULTIPLY", c.m("MULTIPLY", hit, is_left), c.m("SUBTRACT", 1.0, is_road), "合体する左")
right_kill = c.m("MULTIPLY", hit, c.m("SUBTRACT", 1.0, is_left), "消える右")
kill = _v(_math(ng, "MAXIMUM", kill, right_kill, "消す点(合体込み)", (960, -1900), fm))

Blender の画面で確かめるなら、同じコマンドに --save を付けて out にできる .blend を開き、Geometry Nodes タブのノードエディタで枠 ⑤ 隣の区画と合体(左を残して右を消す) を見ます。左のスプレッドシートで Instances を選ぶと、列 sx(合体した建物は 2.25) に値が入っているのが確かめられます。

【応用】住宅街・商店街・工業地帯に分ける

5つの要素で、建物1棟ずつはばらけました。ところが少し引いて見ると、どこを見ても同じ「ばらけ方」をしています。現実の街は、住宅が並ぶ場所、店やビルが集まる場所、工場が広がる場所で、まったく顔つきが違います。そこで街を用途で分けました。この部分は Windows 機で試作したものを、5つの要素の後ろにそのまま積んでいます。

v6:用途で「値の決め方」を差し替える

作り方は、建物の作りには手を付けず、値を決めている Math ノードの出口だけを差し替えるやり方です(zones_v6.py)。

  1. 40区画(80m四方)ごとの地区に分け、中心からの「四角い距離」で、中心=商店街、その外=住宅街、外縁=工業地帯にする。境目は地区ごとの乱数で少しゆらす(まっすぐな境界だと地図記号のように見える)
  2. 用途ごとに、階数・種類・屋根・色・横幅を差し替える。住宅は1〜3階・切妻75%・白とベージュ、商店は3階〜(中心ほど高く最大15階)・平屋根・青みグレー、工業は1〜2階・低層ワイド・横に1.6倍
  3. 用途を属性 zone として記録する(0=住宅、1=商店、2=工業)

Python から組み上がったノードツリーをいじるときに1つつまずきました。link.to_node is node のように is で比べると、必ず偽になります。Blender は参照するたびに別の Python のオブジェクトを返すからです。エラーにはならないので、つなぎ替える相手が1つも見つからないまま静かに通ってしまいます。名前(link.to_node.name == node.name)で比べれば大丈夫です。

v7:住宅は街区の外周だけに建て、工業は1街区に2棟

v6 は、街区いっぱいに建物が並んだまま用途を変えただけで、1街区に約32棟ありました。住宅街なのに、道路に面していない家が半分近くあります。そこで v7 では、住宅は街区の外周だけに建て、家を 0.72 倍に縮めて家どうしにすき間を作り、内側は庭(緑)と駐車場(灰)の板を敷きました。工業地帯は街区まるごとを1つの敷地にし、生産棟(4×3区画・2階)とオフィス棟(3〜4階)の2棟だけを建てます。合体は商店街だけにしました。

v8:住宅を「敷地ごと」に作る

v7 は、庭が街区の内側にまとまっていて共有の庭に見え、駐車場も道路に面していませんでした。v8 では、敷地という単位を作りました(zones_v8.py)。

  • 敷地は間口1区画 × 奥行き2区画。1街区に16敷地、真ん中の2×2は共有の裏庭
  • 庭・駐車場・柵を1つのメッシュ(26面)にまとめ、1戸に1つ置く。柵は全周で、手前だけ駐車場のぶん開けて車の出入口にする
  • 家は敷地の中央やや奥、駐車場は手前(道路側)
  • 敷地の部品を家と同じ向きで置く。段階1で家の正面を道路に向けているので、敷地の手前も必ず道路側になり、4方向の作り分けがいらない
住宅街の同じ場所を3段階で比較。v6は家が肩を寄せ合って並ぶ、v7は外周に家が並び内側が庭と駐車場、v8は柵で囲った敷地ごとに家と駐車場がある v8の住宅街の寄り。柵で囲まれた敷地の手前に駐車場、中央やや奥に切妻屋根の家、街区の真ん中に共有の裏庭がある v8の工業地帯。舗装したヤードの中に大きな生産棟と小さなオフィス棟が建ち、奥に住宅街が続く

真上から建物1棟を1つの四角で描くと、用途の分かれ方が一目で分かります。左の v6 は街区がべた塗り、右の v8 は住宅街が四角い輪になり、工業地帯は1街区に大きな塊と小さな点が1つずつです。

真上から見た区画図。中心がオレンジの商店街、その外が緑の住宅街、外縁が灰色の工業地帯。v6は街区がべた塗りで、v8は住宅が街区の外周に輪のように並ぶ

属性に何が入っているかは、Geometry Nodes タブの左にあるスプレッドシートで確かめられます。左の一覧で Instances を選び、part == 0(建物の本体だけ)で絞り込むと、1棟ずつの値が並びます。下の先頭の行は工業地帯(zone=2)の生産棟で、幅 sx 4.6・奥行き sy 3.4 になっています。

Blenderのスプレッドシート。建物の分身ごとに gx、gy、part、type、h、floors、col_id、roof、rot、sx、zone、sy の値が並ぶ

用途地域は「ばらつかせる」ではなく「そろえる」道具

数字で見ると、用途地域はこれまでと逆の方向に働きました。

隣どうしの組のうち、似ている組の割合(grid=161・Windows)0%10%20%30%24.73.6v0 p9734.80.7v1 向き・色4.81.9v2 階数2.40.9v3 屋根2.40.9v4 窓1.90.7v5 合体25.011.4v6 用途12.73.5v7 外周14.23.7v8 敷地ここから用途地域同じ形と色(高さは問わない)そっくり(さらに高さの差10%以内)

v6 でそっくりは 0.7% → 11.4%、同じ形と色は 1.9% → 25.0% に跳ね上がりました。p973 とほぼ同じ水準です。同じ用途の中では、似た高さ・似た屋根・似た色の建物が並ぶからです。それでも街路から見ると、p973 のような「同じ箱が並んでいる」印象にはなりません。住宅街は住宅街らしく、そろっていて当然だからです。

v1〜v5 で建物1棟ずつをばらつかせ、v6〜v8 で場所ごとにそろえる。この2つは逆向きの道具で、両方そろって初めて街らしく見えた、というのがこの章で分かったことです。そっくり率は「低いほど良い」数字ではなく、ばらつかせる道具が効いているかを確かめる物差しとして使うのがよさそうです。

v8の街全体を斜め上から。中心に高いビルの商店街、その周りに住宅街、外縁に大きな工場が並ぶ

再現:用途地域(v6〜v8)

Blender -b --factory-startup --python run_stages.py -- --grid 161 --stages v6_zones,v7_zones,v8_lots --render --views houses,industry,lots
[p1002] v6_zones           bld=11785 inst=33235 twins=0.1138 looks=246
[p1002] v7_zones           bld=5218 inst=33376 twins=0.0352 looks=221
[p1002] v8_lots            bld=4439 inst=32492 twins=0.0371 looks=221

用途を決める要点(zones_v8.py)。中心からの「四角い距離」に、地区ごとの乱数で±0.06のゆらぎを足して分けます。

u = c3.m("DIVIDE", c3.m("MAXIMUM", cx, cy), half_n, "中心からの四角い距離")
u2 = c3.m("ADD", u, c3.m("MULTIPLY", c3.m("SUBTRACT", r_z, 0.5), 0.12), "ゆらぎ込み")
c4 = cv._Ctx(ng, -360, -5300, f)
is_shop = c4.m("LESS_THAN", u2, 0.30, "商店か")
c5 = cv._Ctx(ng, -360, -5460, f)
is_ind = c5.m("MULTIPLY", c5.m("GREATER_THAN", u2, 0.82), c5.m("SUBTRACT", 1.0, is_shop), "工業か")
c6 = cv._Ctx(ng, -360, -5620, f)
is_home = c6.m("SUBTRACT", c6.m("SUBTRACT", 1.0, is_shop), is_ind, "住宅か")
zone = c6.m("ADD", is_shop, c6.m("MULTIPLY", is_ind, 2.0), "用途 0住宅/1商店/2工業")

Blender の画面で確かめるなら、同じコマンドに --save を付けて out にできる .blend を開き、Geometry Nodes タブのノードエディタで枠 ⑦ 用途地域(住宅=敷地 / 工業=2棟) を見ます。左のスプレッドシートで Instances を選ぶと、列 zone(0住宅・1商店・2工業)と sy に値が入っているのが確かめられます。 敷地の部品は枠 ⑧ 敷地(柵・駐車場・庭)と工場のヤード で置いています。

【計測・Windows】重さは、建物の数より分身の数で決まる

ここからは重さです。p973 と同じく、重さを2種類に分けて測りました。

  • 再計算:スライダーを動かしたときに街を作り直す時間(CPU)。グリッドの大きさをほんの少し揺らして作り直させ、7回の中央値を取る
  • 描画:視点を回したときの1コマの時間。カメラを12段階で1周させながら、Blender の計測用の命令(wm.redraw_timer)で3Dビューを描き直させる。ソリッド表示とマテリアル表示の2つ

計測は Windows 機(RTX 3060)の画面ありの Blender で行い、同じ計測を2回通して、ほぼ同じ数字になることを確かめました(例:grid=161 の段階5のマテリアル表示は 32.72ms と 33.60ms)。

視点を回したときの1コマの時間(grid=161・Windows/短いほど軽い)0ms10ms20ms30ms40msv0 p97317.44.8分身 29,521v1 向き・色19.74.7分身 29,521v2 階数20.14.9分身 29,521v3 屋根26.36.1分身 35,506v4 窓34.46.2分身 35,506v5 合体33.65.9分身 33,804v6 用途31.05.8分身 33,235v7 外周24.95.6分身 33,376v8 敷地22.85.2分身 32,492実体化2.50.8分身 0マテリアル表示ソリッド表示
段階(grid=161)建物分身描く面再計算ソリッドマテリアル
v0 p973 の作り12,96029,521178,2174.03ms4.80ms17.35ms
v1 向きと色12,96029,521178,2174.73ms4.73ms19.69ms
v2 階数12,96029,521178,2175.10ms4.86ms20.10ms
v3 屋根12,96035,506209,8537.42ms6.14ms26.29ms
v4 窓12,96035,506209,8537.19ms6.21ms34.43ms
v5 合体11,78533,804194,8017.95ms5.90ms33.60ms
v6 用途11,78533,235146,7429.10ms5.79ms30.98ms
v7 外周+2棟5,21833,376106,21610.49ms5.58ms24.92ms
v8 敷地ごと4,43932,492約178,00010.31ms5.24ms22.78ms

表から読めることは、次の4つです。

  1. 屋根を別の部品にした段階3で、分身が2割増え、ソリッド表示が 4.86 → 6.14ms、マテリアル表示が 20.10 → 26.29ms に増えた。建物の数は1棟も増えていません。p973 で見つけた「描画の重さは面の数より分身の数で決まる」が、ここでもそのまま出ました
  2. 窓(段階4)は、マテリアル表示を grid=161 で1.3倍にしたが、grid=81 と 241 では増えなかった(81:8.42 → 8.13ms、241:52.98 → 49.26ms)。2回測っても同じ形でした。形を増やしたときほど、はっきりした重さではありません
  3. v8 は、建物が v5 の4割になったのに、分身の数はほとんど減っていない(33,804 → 32,492)。庭の板や敷地の部品が増えたからです。ソリッド表示は 5.90 → 5.24ms とあまり変わらず、マテリアル表示は 33.60 → 22.78ms に下がりました
  4. v8 の敷地は1つ26面あるのに、v7(庭を板で敷く)と重さはほぼ同じでした(grid=161 で v8 が少し軽く、241 で v8 が7%重い)。柵や駐車場を本物の形で作っても、1戸1部品にまとめれば、分身の数は増えないので大きくは重くなりません

再計算は、要素を足すたびに少しずつ伸び、v0 の 4.03ms から v8 の 10.31ms まで2.6倍になりました。それでも 10ms 程度なので、スライダーを動かして待たされる感じはありません。v8 では用途地域のぶんノードが約150個増え(212 → 365個)、再計算は3割増えています。

再現:重さの計測(画面が開きます)

Blender --factory-startup --python measure.py -- --grids 161 --stages v2_floors,v3_roofs --out results --quit
[p1002] v2_floors n=161 inst=29521 eval=5.1ms draw(solid)=4.86ms draw(material)=20.1ms
[p1002] v3_roofs n=161 inst=35506 eval=7.42ms draw(solid)=6.14ms draw(material)=26.29ms

結果は results/measure.csv にも出ます(上は Windows の値)。GPU の使用率と電力は、probe_render.py -- --stage v5_merge --grid 161 --out out --hold 20 でマテリアル表示のまま20秒描き続けさせ、そのあいだに別のウィンドウで nvidia-smi --query-gpu=utilization.gpu,power.draw,clocks.sm --format=csv -lms 300 を動かして記録しました(NVIDIA の GPU のみ)。

重い段階では、GPU が遊んでいた

マテリアル表示が重い段階5を描き続けながら、GPU の使用率と電力を外から記録しました(grid=161)。

描いているもの1コマGPU 使用率GPU 電力GPU の動作クロック
段階5(分身 33,804)31.58ms27%30W1,237MHz
段階5を実体化(分身 0)2.76ms85%119W1,920MHz

分身が多いときは、GPU は 27%・30W で、ほとんど休んでいました。詰まっているのは CPU 側で、分身を1つずつ描画の命令に積む処理です。実体化して1つの大きな形にすると、GPU が 85%・119W まで上がり、11倍速くなりました。分身を増やす作りは、GPU の性能を上げても速くなりにくい、ということです。

【実験・Windows】型を増やすか、部品を組み立てるか

建物のバリエーションの作り方には、大きく2つあります。

  • 部品を組み立てる(この記事の本編):本体4種と屋根2種を別々に置く。組み合わせは自由だが、屋根のぶん分身が増える
  • 型を焼き込む:本体と屋根を1つにした「型」をN種類あらかじめ作っておき、1棟に1つ置く。分身は増えない

型を 4・16・64 種類にした街と、部品を組み立てた街を実体化したものも加えて、同じ物差しで測りました(見た目の条件は段階5にそろえています)。

作り方(grid=161)分身再計算ソリッドマテリアル
部品を組み立てる(v5)33,8047.95ms5.90ms33.60ms
型を焼き込む・4種28,3466.30ms5.03ms26.38ms
型を焼き込む・16種28,3466.14ms3.70ms24.57ms
型を焼き込む・64種28,3466.63ms3.87ms24.47ms
部品を組み立てて実体化019.72ms0.81ms2.48ms
  • 型を 4 → 64 種に増やしても、重さは変わらない。grid=81 と 241 でも同じでした(241 のマテリアル表示は 55.95 / 56.78 / 55.06ms)。形の種類の数は、分身の数ほど効きません
  • 焼き込みは、分身が少ないぶん、部品を組み立てる作りより2〜3割軽い
  • 実体化は、描画がソリッドで7倍・マテリアルで13倍速い一方、再計算は2.5倍遅い。grid=241 では再計算が 35.43ms になり、スライダーを動かすと引っかかりを感じ始めます

それでも本編で部品の作りを選んだのは、焼き込みには見た目の弱点があるからです。高さは分身の縦の倍率で変えるので、型に含めた屋根まで一緒に縦に伸びてしまいます。下の左の画像で、高いビルの上の切妻屋根がとがって見えるのがそれです。避けるには高さの段階ごとに型を作るしかなく、型の数が一気に増えます。右は部品を組み立てて実体化したもので、窓も屋根も崩れていません。

左は型を16種焼き込んだ街で、高いビルの上の切妻屋根が縦に伸びてとがっている。右は部品を組み立てて実体化した街で、窓の格子も屋根も正しく描かれている

作業中は部品のまま(再計算が軽い)、仕上がったら実体化(描画が軽い)、と使い分けるのが良さそうです。

再現:型の持ち方(画面が開きます)

Blender --factory-startup --python measure.py -- --grids 161 --stages v5_merge,e3_baked4,e3_baked16,e3_baked64,e3_parts_realize --out results --quit

型の数は STAGES の baked_n で決めています(city_var.py)。

"e3_baked4":   dict(orient=True, color=True, floors=True, windows=True, merge=True,
                    approach="baked", baked_n=4),
"e3_baked16":  dict(orient=True, color=True, floors=True, windows=True, merge=True,
                    approach="baked", baked_n=16),
"e3_baked64":  dict(orient=True, color=True, floors=True, windows=True, merge=True,
                    approach="baked", baked_n=64),

屋根が伸びる様子は run_stages.py -- --grid 161 --stages e3_baked16 --render --views detail で撮れます。

【つまずき】測るときに踏んだ落とし穴

数字の裏で、計測そのものが間違っていた場面が3つありました。どれも、エラーは何も出ません。

1. マテリアル表示は「一度レンダリングしたか」で10倍変わる

同じ条件を測り直したら、マテリアル表示が 68ms から 6ms に変わりました。調べると、EEVEE で一度レンダリングする前の3Dビューは、マテリアル表示が軽い描き方になっていたのです。

段階5・grid=161 を測った順番ソリッドマテリアル
レンダリングする前7.76ms3.08ms
EEVEE で1回レンダリングしたあと7.46ms32.56ms
さらにもう1回のあと7.50ms32.47ms

計測スクリプトは1条件ごとに証拠の画像をレンダリングしていたので、2条件目からは本来の重さで測れていましたが、最初の1条件だけが軽く出ていました。シェーダーのコンパイル中かどうか(bpy.app.is_job_running("SHADER_COMPILATION"))を見ても、この状態は見分けられません。計測の最初に捨てのレンダリングを1回入れて直しました(measure.py)。

2. ノードの名前は64バイトで切れる

用途地域の枠に「⑦ 用途地域(住宅=敷地ごと / 工業=生産棟+オフィス棟)」と名前を付けたら、保存した .blend では「…工業=生産棟+オ」で切れていました。ノードのラベルは64バイトまでで、日本語は1文字3バイトなので約21文字が上限です。エラーは出ず、名前でノードを探すスクリプトが見つけられなくなります。

3. 敷地の面が0面と数えられていた

v8 の「描く面」は、最初 95,989面と出ました。Collection Info で丸ごと置いた敷地の部品が、0面として数えられていたのです。分身が指す中身が、メッシュではなく GeometrySet として返ってくるためで、中までたどって数えると約178,000面になります(city_var.py の ref_faces())。

再現:レンダリングの前と後(画面が開きます)

Blender --factory-startup --python probe_render.py -- --stage v5_merge --grid 161 --out out
[order] render前: solid 4.46 ms / material 7.8 ms
[order] render1回のあと: solid 4.43 ms / material 19.4 ms
[order] render2回のあと: solid 5.09 ms / material 18.75 ms

上は Mac(M4)で動かした結果です。差は2.5倍で Windows の10倍より小さいものの、レンダリングの前だけ軽く出るのは同じでした。

自分の街に足すなら、どの順で足すか

最後に、この記事の数字を「どれから足すか」の目安にまとめます。

足すもの見た目への効き方重さおすすめ
乱数を区画の番号から作る見た目は変わらないほぼ変わらない最初にやる。あとで建物を消す要素を足しても、街が入れ替わらない
向きと色いちばん効く(同じ形と色 24.7% → 4.8%)ほぼ変わらない次にやる
窓(質感で描く)街路から見たときに大きく変わる形は増えない。描画は街の大きさによって0〜3割形で作らず、マテリアルで描く
屋根(別の部品)上から見たときに効く分身が2割増え、描画が2〜3割重い重いときは型に焼き込む(屋根が伸びる点に注意)
階数空が広がり自然になる。そっくりは増えるほぼ変わらない窓の段をそろえたいなら入れる
合体横長の建物で単調さが減るほぼ変わらない道路をまたがない組で合体させる
用途地域・敷地場所ごとに顔つきが変わる。そっくりは増える再計算が3割増え、描画は面が減るぶん軽くなるばらつかせたあとに、そろえる道具として足す

実測した環境
Windows(画像・重さ・GPU の記録):Windows 11 Pro(10.0.26300)/Ryzen 7 7840HS/メモリ31.3GB/GeForce RTX 3060 12GB(OCuLink接続・ドライバ 616.92)
Mac(スクリプトの作成・ノード画面の撮影・Windows との突き合わせ):Apple M4/メモリ32GB/macOS 27.0.1
Blender はどちらも 5.2.2 LTS(hash d13f752e3b9c)で、執筆時点の最新版です。2026-10-07 に測りました。画像は EEVEE・1600×900・64サンプルです。

【付録】スクリプト全文と動かし方

この記事の街はすべてスクリプトで組み立てています。8本の全文を、この章の最後に折りたたんで載せました。それぞれ同じ名前のファイルに保存すれば、Blender 5.2 LTS だけで動きます(追加のインストールは不要)。

ファイル役割
city_var.py街のノードツリーを段階ごとに組み立てる本体。段階の一覧は末尾の STAGES
zones_v6.py〜zones_v8.py用途地域。city_var.build_stage("v6_zones") などから呼ばれる
run_stages.py段階ごとの街を作り、そっくり率を数え、画像を撮る(画面なしでよい)
measure.py再計算と描画を測る(画面ありで起動する)
compare_rows.py2つの街の建物を区画ごとに突き合わせる(乱数の作り方の比較、Mac と Windows の比較)
probe_render.pyレンダリングの前と後で描画の重さを比べる。--hold で描き続ける(GPU の記録用)

8本とも同じフォルダに置いてください。先に実行しておく必要のあるファイルはありません。どのコマンドから実行しても大丈夫です。順番の決まりはない代わりに、次のように中で互いを読み込んでいます。

  • run_stages.py と measure.py は、city_var.py を読み込んで街を組み立てる。measure.py は run_stages.py が作ったファイルを使わず、自分で街を作り直して測る
  • city_var.py は、v6〜v8(用途地域)を作るときだけ zones_v6.py〜zones_v8.py を読み込む。v0〜v5 だけなら、この3本はなくても動く
  • zones_v6.py〜zones_v8.py は、逆に city_var.py で v5 の街を作ってから、そこに足す。単体でも実行できる
  • probe_render.py は city_var.py を読み込む。compare_rows.py は run_stages.py --dump が書き出した rows_*.json を2つ読むだけで、ほかのファイルは使わない

保存したフォルダで、次のように動かします。コマンドの先頭の Blender は、Mac なら /Applications/Blender.app/Contents/MacOS/Blender、Windows なら "C:\Program Files\Blender Foundation\Blender 5.2\blender.exe"(インストーラー版の既定の場所)に置き換えてください。Mac では3つとも、この形のまま動くことを確かめました(3つ目は grid=81 の13段階で約1分です)。各章の「再現」に、その章だけを試すコマンドを載せています。

# 段階ごとに街を作って数える(画面なし)。--render を付けると画像も撮る
Blender -b --factory-startup --python run_stages.py -- --grid 81 --out out

# v8 を作って .blend に保存(開いてノードを眺める用)
Blender -b --factory-startup --python run_stages.py -- --grid 81 --out out --save --stages v8_lots

# 重さを測る(画面が開く。終わるまで触らない)
Blender --factory-startup --python measure.py -- --grids 81 --out results --quit

1つ目を grid=161 で動かすと、段階ごとに次のような行が出ます(bld=建物の数、inst=分身の数、twins=そっくりの割合、looks=見た目の種類)。

[p1002] v0_p973            bld=12960 inst=29521 twins=0.036 looks=187
[p1002] v1_orient          bld=12960 inst=29521 twins=0.0069 looks=1149
[p1002] v2_floors          bld=12960 inst=29521 twins=0.0192 looks=594
[p1002] v3_roofs           bld=12960 inst=35506 twins=0.0094 looks=922
[p1002] v4_windows         bld=12960 inst=35506 twins=0.0094 looks=922
[p1002] v5_merge           bld=11785 inst=33804 twins=0.0071 looks=1215
[p1002] v6_zones           bld=11785 inst=33235 twins=0.1138 looks=246
[p1002] v7_zones           bld=5218 inst=33376 twins=0.0352 looks=221
[p1002] v8_lots            bld=4439 inst=32492 twins=0.0371 looks=221

Mac で同じコマンドを動かすと、空き地の判定が食い違う13区画のぶんだけ、建物の数が1つずれることがあります(下準備の章)。

city_var.py … 街のノードツリーを段階ごとに組み立てる本体・1150行
# -*- coding: utf-8 -*-
"""
p1002: Geometry Nodesで建物にバリエーションをつける
段階ビルド用ライブラリ(Blender 5.2 LTS で確認)

出発点は p973 の最終形(道路・起伏・建物4種・中心ほど高いゾーニング)。
そこに次の5つを1つずつ足していく。

  v1 向きと色   … 建物の正面を近い道路へ向ける/外壁の色を6色から選ぶ
  v2 階数       … 高さを「階数 × 1階ぶん」で決め、中心ほど階数を増やす
  v3 屋根3種    … 平(そのまま)・切妻・段違い(塔屋)を別の部品として載せる
  v4 窓         … 形は増やさず、マテリアル(質感)で窓を描く
  v5 区画の合体 … となり合う2区画を1棟にまとめる

型の持ち方の比較(実験3)のために、
  approach="parts" … 本体と屋根を別々の分身で置く(v3以降の既定)
  approach="baked" … 本体+屋根を1つにした型をN種類用意して、1棟1分身で置く
も切り替えられるようにしてある。

すべてノードの bl_idname で組み立てているので、UIの言語設定に依存しない。
"""

import bpy
import math
import random

# ---------------------------------------------------------------- 定数

FLOOR_H = 0.35        # 1階ぶんの高さ(区画2m=建物の幅1.6に対して)
WIN_PITCH = 0.40      # 窓の横の間隔
GABLE_H = 0.45        # 切妻屋根の高さ
MERGE_SX = 2.25       # 2区画を1棟にしたときの横の倍率((2区画-すき間)/建物の幅)

# 外壁の6色(線形の値)。並びは「寒色 → 暖色」で、中心ほど前のほうが出やすい
FACADE_COLORS = [
    (0.16, 0.20, 0.25, 1.0),   # 0 青みのグレー(ガラス張りのビル風)
    (0.30, 0.31, 0.33, 1.0),   # 1 グレー
    (0.58, 0.56, 0.52, 1.0),   # 2 白っぽい
    (0.52, 0.42, 0.30, 1.0),   # 3 ベージュ
    (0.40, 0.19, 0.13, 1.0),   # 4 レンガ
    (0.21, 0.14, 0.10, 1.0),   # 5 こげ茶
]
ROOF_COLORS = [
    (0.07, 0.08, 0.10, 1.0),   # スレート
    (0.36, 0.13, 0.07, 1.0),   # 素焼き瓦
    (0.09, 0.16, 0.18, 1.0),   # 青緑
]
PLAIN_BUILDING = (0.20, 0.21, 0.24, 1.0)   # p973 と同じ色


# ---------------------------------------------------------------- 小道具

def _sock(node, names, in_out="inputs"):
    """ソケットを名前で引く。版で名前が変わることがあるので候補を複数受け付ける。
    同じ名前のソケットが型違いで複数あるノードに備え、有効なものを優先する。"""
    coll = node.inputs if in_out == "inputs" else node.outputs
    if isinstance(names, int):
        return coll[names]
    if isinstance(names, str):
        names = [names]
    for n in names:
        hits = [s for s in coll if s.name == n]
        for s in hits:
            if getattr(s, "enabled", True):
                return s
        if hits:
            return hits[0]
    raise KeyError(f"{node.bl_idname}: socket {names} not found "
                   f"(available: {[s.name for s in coll]})")


def _new_node(ng, idname, label=None, loc=(0, 0), parent=None, **props):
    n = ng.nodes.new(idname)
    if label:
        n.label = label
    n.location = loc
    if parent is not None:
        n.parent = parent
    for k, v in props.items():
        setattr(n, k, v)
    return n


def _link(ng, a, a_sock, b, b_sock):
    ng.links.new(_sock(a, a_sock, "outputs"), _sock(b, b_sock, "inputs"))


def _frame(ng, label, loc):
    f = ng.nodes.new("NodeFrame")
    f.label = label
    f.location = loc
    f.shrink = True
    return f


def _math(ng, op, a=None, b=None, label=None, loc=(0, 0), parent=None, shader=False):
    """Math ノードを1つ作り、入力に値かソケットを差し込む。
    a, b は 数値 / (node, socket名) のどちらでもよい"""
    n = _new_node(ng, "ShaderNodeMath", label=label, loc=loc, parent=parent)
    n.operation = op
    for i, v in enumerate((a, b)):
        if v is None:
            continue
        if isinstance(v, tuple):
            ng.links.new(_sock(v[0], v[1], "outputs"), n.inputs[i])
        else:
            n.inputs[i].default_value = float(v)
    return n


def _v(node):
    """Math ノードの出力を (node, "Value") の形で返す(読みやすさのため)"""
    return (node, "Value")


class _Ctx:
    """ノードを左から右へ並べるための簡単な座標係"""
    def __init__(self, ng, x, y, frame=None):
        self.ng, self.x, self.y, self.frame = ng, x, y, frame

    def m(self, op, a=None, b=None, label=None):
        n = _math(self.ng, op, a, b, label, (self.x, self.y), self.frame)
        self.x += 170
        return _v(n)


def _named(ng, name, loc, parent=None, dtype="FLOAT"):
    n = _new_node(ng, "GeometryNodeInputNamedAttribute", f"属性 {name}", loc, parent)
    n.data_type = dtype
    _sock(n, "Name").default_value = name
    return (n, "Attribute")


def _store(ng, geo, name, value, loc, parent=None, domain="POINT", dtype="FLOAT"):
    """Store Named Attribute。value は数値か (node, socket)"""
    n = _new_node(ng, "GeometryNodeStoreNamedAttribute", f"{name} を記録", loc, parent)
    n.data_type = dtype
    n.domain = domain
    _link(ng, geo[0], geo[1], n, "Geometry")
    _sock(n, "Name").default_value = name
    if isinstance(value, tuple):
        ng.links.new(_sock(value[0], value[1], "outputs"), _sock(n, "Value"))
    else:
        _sock(n, "Value").default_value = value
    return (n, "Geometry")


def _rand(ng, id_src, seed, loc, parent=None, label=None):
    """区画ごとに決まる0〜1の乱数。ID に区画の番号を渡すので、
    ほかの建物を消したり足したりしても、その区画の値は変わらない"""
    n = _new_node(ng, "FunctionNodeRandomValue", label or f"乱数(seed {seed})", loc, parent)
    n.data_type = "FLOAT"
    _sock(n, "Min").default_value = 0.0
    _sock(n, "Max").default_value = 1.0
    ng.links.new(_sock(id_src[0], id_src[1], "outputs"), _sock(n, "ID"))
    _sock(n, "Seed").default_value = seed
    return (n, "Value")


# ---------------------------------------------------------------- ノードの整列

def _est_h(n):
    """ノードの高さの見積もり(画面に出す前は dimensions が 0 のため)"""
    if n.dimensions[1] > 0:
        return n.dimensions[1]
    ins = [s for s in n.inputs if getattr(s, "enabled", True) and not s.hide]
    outs = [s for s in n.outputs if getattr(s, "enabled", True) and not s.hide]
    extra = 60 if n.bl_idname in ("ShaderNodeMath", "ShaderNodeVectorMath", "FunctionNodeRandomValue",
                                  "GeometryNodeStoreNamedAttribute", "GeometryNodeInputNamedAttribute",
                                  "ShaderNodeAttribute", "GeometryNodeDeleteGeometry") else 20
    if n.bl_idname == "ShaderNodeValToRGB":
        extra = 200
    return 50 + 22 * (len(ins) + len(outs)) + extra


def tidy_layout(ng, col_w=210, gap=24, row_max=7000, block_gap=260):
    """記事の画面写真で読めるよう、ノードを並べ直す。
    枠(フレーム)ごとに、つながりの深さで左から右へ列を作り、枠どうしは重ならないよう敷き詰める"""
    nodes = [n for n in ng.nodes if n.bl_idname != "NodeFrame"]
    order = {n.name: i for i, n in enumerate(nodes)}
    groups = {}
    for n in nodes:
        groups.setdefault(n.parent.name if n.parent else "", []).append(n)
    blocks = []
    for key, members in groups.items():
        names = {n.name for n in members}
        preds = {n.name: [] for n in members}
        for l in ng.links:
            if l.from_node.name in names and l.to_node.name in names:
                preds[l.to_node.name].append(l.from_node.name)
        depth = {}

        def d(name, seen=()):
            if name in depth:
                return depth[name]
            if name in seen:
                return 0
            v = 0 if not preds[name] else 1 + max(d(p, seen + (name,)) for p in preds[name])
            depth[name] = v
            return v
        for n in members:
            d(n.name)
        cols = {}
        for n in sorted(members, key=lambda n: order[n.name]):
            cols.setdefault(depth[n.name], []).append(n)
        row_of = {}
        heights = []
        for c in sorted(cols):
            col = cols[c]
            if c > 0:   # 前の列で上にいるものにつながる順に並べる(線の交差を減らす)
                col.sort(key=lambda n: (sum(row_of.get(p, 0) for p in preds[n.name]) /
                                        max(len(preds[n.name]), 1), order[n.name]))
            y = 0
            for n in col:
                row_of[n.name] = y
                y += _est_h(n) + gap
            heights.append(y)
        blocks.append((min(order[n.name] for n in members), key, cols,
                       (max(cols) + 1) * col_w, max(heights) if heights else 0))
    blocks.sort()
    x0 = y0 = 0.0
    row_h = 0.0
    for _, key, cols, w, h in blocks:
        if x0 > 0 and x0 + w > row_max:
            x0, y0, row_h = 0.0, y0 - row_h - block_gap, 0.0
        for c, col in cols.items():
            y = 0
            for n in col:
                n.location_absolute = (x0 + c * col_w, y0 - y - 60)
                y += _est_h(n) + gap
        x0 += w + block_gap
        row_h = max(row_h, h + 60)


# ---------------------------------------------------------------- マテリアル

def _mat_base(name):
    mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
    mat.use_nodes = True
    nt = mat.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    out.location = (900, 0)
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    bsdf.location = (600, 0)
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    return mat, nt, bsdf


def ensure_simple(name, color, roughness):
    mat, nt, bsdf = _mat_base(name)
    bsdf.inputs["Base Color"].default_value = color
    bsdf.inputs["Roughness"].default_value = roughness
    return mat


def _shader_attr(nt, name, loc, vector=False):
    """シェーダーで属性を読む。
    分身のまま(Instancer)と、実体化したあと(Geometry)のどちらでも拾えるよう、2つ足し合わせる。
    無い側は 0 を返すので、足しても値は変わらない。"""
    a = nt.nodes.new("ShaderNodeAttribute")
    a.attribute_type = "GEOMETRY"
    a.attribute_name = name
    a.location = loc
    b = nt.nodes.new("ShaderNodeAttribute")
    b.attribute_type = "INSTANCER"
    b.attribute_name = name
    b.location = (loc[0], loc[1] - 180)
    if vector:
        add = nt.nodes.new("ShaderNodeVectorMath")
        add.operation = "ADD"
        add.location = (loc[0] + 200, loc[1])
        nt.links.new(a.outputs["Vector"], add.inputs[0])
        nt.links.new(b.outputs["Vector"], add.inputs[1])
        return add.outputs["Vector"]
    add = nt.nodes.new("ShaderNodeMath")
    add.operation = "ADD"
    add.location = (loc[0] + 200, loc[1])
    nt.links.new(a.outputs["Fac"], add.inputs[0])
    nt.links.new(b.outputs["Fac"], add.inputs[1])
    return add.outputs["Value"]


def _ramp(nt, fac, colors, loc):
    """0〜1 の値を、区切りごとに決まった色へ(補間なし)"""
    r = nt.nodes.new("ShaderNodeValToRGB")
    r.location = loc
    cr = r.color_ramp
    cr.interpolation = "CONSTANT"
    n = len(colors)
    while len(cr.elements) < n:
        cr.elements.new(0.5)
    for i, c in enumerate(colors):
        cr.elements[i].position = i / n
        cr.elements[i].color = c
    nt.links.new(fac, r.inputs[0])
    return r.outputs["Color"]


def _id_to_fac(nt, id_out, n, loc):
    """色の番号(0〜n-1) → ランプの読み位置 (番号+0.5)/n"""
    a = nt.nodes.new("ShaderNodeMath")
    a.operation = "ADD"
    a.inputs[1].default_value = 0.5
    a.location = loc
    nt.links.new(id_out, a.inputs[0])
    d = nt.nodes.new("ShaderNodeMath")
    d.operation = "DIVIDE"
    d.inputs[1].default_value = float(n)
    d.location = (loc[0] + 170, loc[1])
    nt.links.new(a.outputs["Value"], d.inputs[0])
    return d.outputs["Value"]


def build_building_material(color=False, windows=False):
    """建物のマテリアル。
      color   … 分身ごとの属性 col_id で外壁の色を選ぶ
      windows … 形を増やさず、面の上に窓の格子を描く(★v4)
    窓の位置は、建物メッシュに書き込んだ『元の座標 lpos』と『元の向き lnrm』から計算する。
    テクスチャ座標の Object を使わないのは、実体化(Realize)すると座標の基準が街全体に
    変わってしまい、窓がずれるため。"""
    mat, nt, bsdf = _mat_base("Building")
    bsdf.inputs["Roughness"].default_value = 0.6

    if color:
        cid = _shader_attr(nt, "col_id", (-1400, 400))
        base = _ramp(nt, _id_to_fac(nt, cid, len(FACADE_COLORS), (-1000, 400)),
                     FACADE_COLORS, (-600, 400))
    else:
        rgb = nt.nodes.new("ShaderNodeRGB")
        rgb.location = (-600, 400)
        rgb.outputs[0].default_value = PLAIN_BUILDING
        base = rgb.outputs[0]

    if not windows:
        nt.links.new(base, bsdf.inputs["Base Color"])
        return mat

    L = nt.links

    def M(op, a, b=None, x=0, y=0):
        n = nt.nodes.new("ShaderNodeMath")
        n.operation = op
        n.location = (x, y)
        for i, v in enumerate((a, b)):
            if v is None:
                continue
            if isinstance(v, (int, float)):
                n.inputs[i].default_value = float(v)
            else:
                L.new(v, n.inputs[i])
        return n.outputs["Value"]

    lpos = _shader_attr(nt, "lpos", (-2200, -200), vector=True)
    lnrm = _shader_attr(nt, "lnrm", (-2200, -600), vector=True)
    floors = _shader_attr(nt, "floors", (-2200, -1000))
    sx_raw = _shader_attr(nt, "sx", (-2200, -1400))
    sx = M("MAXIMUM", sx_raw, 1.0, -1800, -1400)          # 属性が無いときは1倍

    sp = nt.nodes.new("ShaderNodeSeparateXYZ"); sp.location = (-1800, -200)
    L.new(lpos, sp.inputs[0])
    sn = nt.nodes.new("ShaderNodeSeparateXYZ"); sn.location = (-1800, -600)
    L.new(lnrm, sn.inputs[0])

    # 縦:元の高さ(0〜1) × 階数 → 小数部が「その階のどのあたりか」
    fl = M("MULTIPLY", sp.outputs["Z"], floors, -1500, -100)
    fv = M("FRACT", fl, None, -1330, -100)
    # 横:X向きの面ならY座標、Y向きの面ならX座標(横に伸ばした棟は倍率をかけて実寸に戻す)
    nx = M("ABSOLUTE", sn.outputs["X"], None, -1500, -500)
    use_y = M("GREATER_THAN", nx, 0.5, -1330, -500)
    ux = M("MULTIPLY", sp.outputs["X"], sx, -1500, -300)
    u_diff = M("SUBTRACT", sp.outputs["Y"], ux, -1330, -300)
    # u = ux + (Y座標 - ux) × use_y(Y向きの面なら ux、X向きの面なら Y座標)
    t = M("MULTIPLY", u_diff, use_y, -1160, -300)
    u = M("ADD", ux, t, -990, -300)
    fu = M("FRACT", M("DIVIDE", u, WIN_PITCH, -820, -300), None, -650, -300)

    # 高い建物(12階以上)は窓を大きく=カーテンウォール風
    big = M("GREATER_THAN", floors, 11.5, -1500, -900)
    v_lo = M("SUBTRACT", 0.30, M("MULTIPLY", big, 0.18, -1330, -900), -1160, -900)
    v_hi = M("ADD", 0.80, M("MULTIPLY", big, 0.12, -1330, -1000), -1160, -1000)
    u_lo = M("SUBTRACT", 0.20, M("MULTIPLY", big, 0.14, -1330, -1100), -1160, -1100)
    u_hi = M("ADD", 0.80, M("MULTIPLY", big, 0.14, -1330, -1200), -1160, -1200)

    m_v = M("MULTIPLY", M("GREATER_THAN", fv, v_lo, -480, -100), M("LESS_THAN", fv, v_hi, -480, -200), -310, -150)
    m_u = M("MULTIPLY", M("GREATER_THAN", fu, u_lo, -480, -300), M("LESS_THAN", fu, u_hi, -480, -400), -310, -350)
    nz = M("ABSOLUTE", sn.outputs["Z"], None, -1500, -700)
    side = M("LESS_THAN", nz, 0.5, -1330, -700)                     # 屋上・床には描かない
    in_body = M("LESS_THAN", sp.outputs["Z"], 0.999, -1330, -800)   # 屋上設備には描かない
    has_f = M("GREATER_THAN", floors, 0.5, -1160, -800)            # 階数が無い分身には描かない
    mask = M("MULTIPLY", M("MULTIPLY", m_v, m_u, -140, -250),
             M("MULTIPLY", M("MULTIPLY", side, in_body, -310, -700), has_f, -140, -700), 30, -400)

    glass = nt.nodes.new("ShaderNodeRGB"); glass.location = (-140, 200)
    glass.outputs[0].default_value = (0.035, 0.05, 0.065, 1.0)
    mix = nt.nodes.new("ShaderNodeMix"); mix.location = (200, 300)
    mix.data_type = "RGBA"
    L.new(mask, mix.inputs["Factor"])
    L.new(base, mix.inputs[6])          # A(RGBA)
    L.new(glass.outputs[0], mix.inputs[7])  # B(RGBA)
    L.new(mix.outputs[2], bsdf.inputs["Base Color"])
    rough = M("SUBTRACT", 0.6, M("MULTIPLY", mask, 0.45, 200, -200), 380, -200)
    L.new(rough, bsdf.inputs["Roughness"])
    return mat


def build_roof_material():
    mat, nt, bsdf = _mat_base("Roof")
    bsdf.inputs["Roughness"].default_value = 0.7
    cid = _shader_attr(nt, "col_id", (-1200, 300))
    m3 = nt.nodes.new("ShaderNodeMath"); m3.operation = "FLOORED_MODULO"
    m3.inputs[1].default_value = 3.0; m3.location = (-800, 300)
    nt.links.new(cid, m3.inputs[0])
    col = _ramp(nt, _id_to_fac(nt, m3.outputs["Value"], 3, (-600, 300)), ROOF_COLORS, (-200, 300))
    nt.links.new(col, bsdf.inputs["Base Color"])
    return mat


def ensure_all_materials(color=False, windows=False):
    mats = {
        "Building": build_building_material(color, windows),
        "Roof": build_roof_material(),
        "Road": ensure_simple("Road", (0.045, 0.045, 0.05, 1.0), 0.75),
        "Line": ensure_simple("Line", (0.85, 0.80, 0.35, 1.0), 0.60),
        "Ground": ensure_simple("Ground", (0.09, 0.11, 0.09, 1.0), 0.90),
    }
    for m in mats.values():
        tidy_layout(m.node_tree, col_w=230)
    return mats


# ---------------------------------------------------------------- メッシュ部品

def _box(sx, sy, sz, z0, cx=0.0, cy=0.0):
    """(頂点, 面) を返す。底面が z0"""
    v = []
    for dx in (-0.5, 0.5):
        for dy in (-0.5, 0.5):
            for dz in (0.0, 1.0):
                v.append((cx + dx * sx, cy + dy * sy, z0 + dz * sz))
    f = [(0, 2, 6, 4), (1, 5, 7, 3), (0, 4, 5, 1),
         (2, 3, 7, 6), (0, 1, 3, 2), (4, 6, 7, 5)]
    return v, f


def _gable(sx, sy, h, z0):
    """切妻屋根(棟がX方向=正面と平行)。正面は -Y"""
    hx, hy = sx * 0.5, sy * 0.5
    v = [(-hx, -hy, z0), (hx, -hy, z0), (hx, hy, z0), (-hx, hy, z0),
         (-hx, 0.0, z0 + h), (hx, 0.0, z0 + h)]
    f = [(0, 1, 5, 4), (2, 3, 4, 5), (1, 2, 5), (3, 0, 4), (3, 2, 1, 0)]
    return v, f


def _make_mesh(name, parts, mats, with_local=True):
    """parts = [(verts, faces, material_index), ...] を1つのメッシュにまとめる。
    with_local: 窓の計算に使う『元の座標 lpos』『元の向き lnrm』を書き込む"""
    me = bpy.data.meshes.new(name)
    verts, faces, mi = [], [], []
    for vs, fs, m in parts:
        off = len(verts)
        verts += vs
        faces += [tuple(i + off for i in f) for f in fs]
        mi += [m] * len(fs)
    me.from_pydata(verts, [], faces)
    me.update()
    for m in mats:
        me.materials.append(m)
    for p, m in zip(me.polygons, mi):
        p.material_index = m
    if with_local:
        a = me.attributes.new("lpos", "FLOAT_VECTOR", "POINT")
        a.data.foreach_set("vector", [c for vv in me.vertices for c in vv.co])
        b = me.attributes.new("lnrm", "FLOAT_VECTOR", "FACE")
        b.data.foreach_set("vector", [c for p in me.polygons for c in p.normal])
    return me


def _fresh_collection(name):
    old = bpy.data.collections.get(name)
    if old:
        for ob in list(old.objects):
            bpy.data.objects.remove(ob, do_unlink=True)
        bpy.data.collections.remove(old)
    return bpy.data.collections.new(name)


def _add(coll, name, me):
    ob = bpy.data.objects.new(name, me)
    coll.objects.link(ob)
    return ob


BODY_COLL = "p1002_Bodies"
ROOF_COLL = "p1002_Roofs"


def build_body_collection(mats, cell=2.0, orient=False):
    """建物の本体4種(p973と同じ寸法)。高さ1でつくり、分身のZ倍率で伸ばす。
    orient=True のときだけ、屋上設備(C)を建物の奥へ寄せる(向きが画で分かるように)"""
    coll = _fresh_collection(BODY_COLL)
    bm = [mats["Building"]]
    fp = 0.80 * cell
    a = _box(fp, fp, 1.0, 0.0)
    b1 = _box(fp * 1.06, fp * 1.06, 0.55, 0.0)
    b2 = _box(fp * 0.69, fp * 0.69, 0.45, 0.55)
    back = fp * 0.22 if orient else 0.0           # 正面は -Y。奥は +Y
    c1 = _box(fp, fp, 1.0, 0.0)
    c2 = _box(fp * 0.38, fp * 0.38, 0.12, 1.0, 0.0, back)
    c3 = _box(fp * 0.18, fp * 0.55, 0.07, 1.0, fp * 0.25, back * 0.6)
    d = _box(fp * 1.15, fp * 1.15, 1.0, 0.0)
    # Separate Children は名前順。A/B/C/D の順に並ぶよう名前を付ける
    _add(coll, "bld_A_box", _make_mesh("bld_A", [(*a, 0)], bm))
    _add(coll, "bld_B_setback", _make_mesh("bld_B", [(*b1, 0), (*b2, 0)], bm))
    _add(coll, "bld_C_rooftop", _make_mesh("bld_C", [(*c1, 0), (*c2, 0), (*c3, 0)], bm))
    _add(coll, "bld_D_low", _make_mesh("bld_D", [(*d, 0)], bm))
    return coll


def build_roof_collection(mats, cell=2.0):
    """屋根の部品2種。『平』は部品を置かない(=本体の屋上がそのまま見える)"""
    coll = _fresh_collection(ROOF_COLL)
    fp = 0.80 * cell
    g = _gable(fp * 1.06, fp * 1.06, 1.0, 0.0)          # 少しだけ軒を出す
    p = _box(fp * 0.55, fp * 0.55, 1.0, 0.0)            # 塔屋(段違い)
    _add(coll, "roof_1_gable", _make_mesh("roof_gable", [(*g, 0)], [mats["Roof"]], with_local=False))
    _add(coll, "roof_2_step", _make_mesh("roof_step", [(*p, 0)], [mats["Building"]]))
    return coll


def build_baked_collection(mats, n, cell=2.0, seed=1002):
    """実験3用:本体+屋根を1つのメッシュに焼き込んだ型を n 種類つくる。
    i 番目の型 = 本体 (i % 4) × 屋根 ((i // 4) % 3) に、寸法を少しずつ揺らしたもの"""
    coll = _fresh_collection(f"p1002_Baked{n}")
    rng = random.Random(seed)
    fp = 0.80 * cell
    for i in range(n):
        body, roof = i % 4, (i // 4) % 3
        k = rng.uniform(0.90, 1.05)          # 敷地に対する太さ
        parts = []
        if body == 0:
            parts.append((*_box(fp * k, fp * k, 1.0, 0.0), 0)); top = k
        elif body == 1:
            r = rng.uniform(0.45, 0.65)
            parts.append((*_box(fp * 1.06 * k, fp * 1.06 * k, r, 0.0), 0))
            parts.append((*_box(fp * 0.69 * k, fp * 0.69 * k, 1.0 - r, r), 0)); top = 0.69 * k
        elif body == 2:
            parts.append((*_box(fp * k, fp * k, 1.0, 0.0), 0))
            parts.append((*_box(fp * 0.38, fp * 0.38, 0.12, 1.0, 0.0, rng.uniform(-0.3, 0.3)), 0))
            roof = 0; top = k
        else:
            parts.append((*_box(fp * 1.15 * k, fp * 1.15 * k, 1.0, 0.0), 0)); top = 1.15 * k
        rh = rng.uniform(0.10, 0.20)          # 屋根の高さ(本体の高さに対する割合)
        if roof == 1:
            parts.append((*_gable(fp * 1.06 * top, fp * 1.06 * top, rh, 1.0), 1))
        elif roof == 2:
            parts.append((*_box(fp * 0.55 * top, fp * 0.55 * top, rh, 1.0), 0))
        _add(coll, f"baked_{i:03d}", _make_mesh(f"baked_{i:03d}", parts,
                                                [mats["Building"], mats["Roof"]]))
    return coll


# ---------------------------------------------------------------- 本体

def build_city_tree(name="CityNodes",
                    grid_n=81, cell=2.0,
                    road_every=8, road_width=2,
                    empty_ratio=0.10,
                    # ★起伏は「基本は平ら、あっても緩やかな坂」にする(Windows での検証で判明・2026-10-07)。
                    #   p973 の設定(振幅6.0・細かさ1.6・Detail 2)だと2〜3区画ごとに高さが変わり、
                    #   区画ごとに平らな板を敷く道路が段差になって波打った(勾配 平均11.4%・最大45.8%)。
                    #   波を12.5倍に伸ばし、細かい段を減らし、高さを下げると 平均0.7%・最大2.8%
                    terrain_amp=1.6, terrain_scale=0.128, terrain_detail=1.0,
                    orient=False,       # v1: 正面を近い道路へ向ける
                    color=False,        # v1: 外壁の色
                    floors=False,       # v2: 階数で高さ
                    roofs=False,        # v3: 屋根3種
                    windows=False,      # v4: 窓(マテリアル)
                    merge=False,        # v5: 隣の区画と合体
                    merge_base=0.15, merge_center=0.25,
                    approach="parts",   # "parts" / "baked"
                    baked_n=4,
                    realize=False,
                    seed_by="cell"):    # "cell"=区画の番号で乱数 / "index"=p973と同じ(点の通し番号)
    """街を生成するジオメトリノードツリーを組み立てて返す"""

    ng = bpy.data.node_groups.get(name)
    if ng:
        bpy.data.node_groups.remove(ng)
    ng = bpy.data.node_groups.new(name, "GeometryNodeTree")
    ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
    ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")

    mats = ensure_all_materials(color=color, windows=windows)
    size = cell * (grid_n - 1)
    half = size * 0.5

    g_out = _new_node(ng, "NodeGroupOutput", loc=(5200, 0))

    # ---------------- ① 地面のグリッドと起伏(p973と同じ:枝分かれの前に起伏を入れる)
    f1 = _frame(ng, "① 地面のグリッドと起伏", (-2200, 400))
    grid = _new_node(ng, "GeometryNodeMeshGrid", "地面グリッド", (-2100, 300), f1)
    _sock(grid, "Size X").default_value = size
    _sock(grid, "Size Y").default_value = size
    _sock(grid, "Vertices X").default_value = grid_n
    _sock(grid, "Vertices Y").default_value = grid_n
    noise = _new_node(ng, "ShaderNodeTexNoise", "ノイズ", (-2100, 0), f1)
    _sock(noise, "Scale").default_value = terrain_scale / max(size, 1e-6) * 40.0
    _sock(noise, "Detail").default_value = terrain_detail
    n_sub = _math(ng, "SUBTRACT", (noise, ["Fac", "Factor"]), 0.5, "中心を0に", (-1900, 0), f1)
    n_mul = _math(ng, "MULTIPLY", _v(n_sub), terrain_amp, "起伏の高さ", (-1730, 0), f1)
    n_vec = _new_node(ng, "ShaderNodeCombineXYZ", "Zだけ動かす", (-1560, 0), f1)
    _link(ng, n_mul, "Value", n_vec, "Z")
    setpos = _new_node(ng, "GeometryNodeSetPosition", "起伏をつける", (-1560, 300), f1)
    _link(ng, grid, "Mesh", setpos, "Geometry")
    _link(ng, n_vec, "Vector", setpos, "Offset")
    smooth = _new_node(ng, "GeometryNodeSetShadeSmooth", "地面をなめらかに", (-1390, 300), f1)
    _link(ng, setpos, "Geometry", smooth, "Mesh")       # 折れ目の陰影を目立たなくする(形は変わらない)
    ground = (smooth, "Mesh")

    # ---------------- ② 区画の点に「何列目・何行目か」を記録する
    # ★あとで建物を消したり寄せたりしても、この番号はついて回る。乱数もここから作る
    f2 = _frame(ng, "② 区画の点に列・行の番号を記録", (-1300, 400))
    m2p = _new_node(ng, "GeometryNodeMeshToPoints", "点にする", (-1250, 300), f2)
    _link(ng, ground[0], ground[1], m2p, "Mesh")
    pos0 = _new_node(ng, "GeometryNodeInputPosition", None, (-1250, 80), f2)
    sep0 = _new_node(ng, "ShaderNodeSeparateXYZ", None, (-1080, 80), f2)
    _link(ng, pos0, "Position", sep0, "Vector")
    c = _Ctx(ng, -910, 120, f2)
    gx_f = c.m("ROUND", c.m("DIVIDE", c.m("ADD", (sep0, "X"), half), cell), None, "列 gx")
    c = _Ctx(ng, -910, -40, f2)
    gy_f = c.m("ROUND", c.m("DIVIDE", c.m("ADD", (sep0, "Y"), half), cell), None, "行 gy")
    pts = _store(ng, (m2p, "Points"), "gx", gx_f, (-300, 300), f2)
    pts = _store(ng, pts, "gy", gy_f, (-130, 300), f2)

    # 以降は記録した番号から計算する(位置を動かしても変わらない)
    fA = _frame(ng, "②' 道路・空き地・合体の判定", (-1300, -400))
    gx = _named(ng, "gx", (-1250, -500), fA)
    gy = _named(ng, "gy", (-1250, -660), fA)
    c = _Ctx(ng, -1080, -480, fA)
    bx = c.m("FLOORED_MODULO", gx, road_every, "ブロック内の列")
    is_col = c.m("LESS_THAN", bx, float(road_width), "縦の道路")
    c = _Ctx(ng, -1080, -640, fA)
    by = c.m("FLOORED_MODULO", gy, road_every, "ブロック内の行")
    is_row = c.m("LESS_THAN", by, float(road_width), "横の道路")
    is_road = _v(_math(ng, "MAXIMUM", is_col, is_row, "どちらかが道路", (-570, -560), fA))
    # 区画の通し番号(p973の Index と同じ値:行 × N + 列)
    c = _Ctx(ng, -1080, -820, fA)
    cid = c.m("ADD", c.m("MULTIPLY", gy, grid_n), gx, "区画の通し番号")
    # 空き地は p973 と同じ式(同じ区画が空き地になる)
    c = _Ctx(ng, -1080, -980, fA)
    r_e = c.m("FRACT", c.m("MULTIPLY", c.m("SINE", c.m("MULTIPLY", cid, 78.233)), 43758.547))
    is_empty = c.m("LESS_THAN", r_e, empty_ratio, "空き地")

    kill = _v(_math(ng, "MAXIMUM", is_road, is_empty, "消す点", (-400, -700), fA))

    # 中心からの近さ z(0=端〜1=中心)と、p973の高さ倍率 zone = 1〜4
    fz = _frame(ng, "中心からの近さ(ゾーニング)", (-1300, -1300))
    c = _Ctx(ng, -1080, -1360, fz)
    wx = c.m("SUBTRACT", c.m("MULTIPLY", gx, cell), half)
    c = _Ctx(ng, -1080, -1520, fz)
    wy = c.m("SUBTRACT", c.m("MULTIPLY", gy, cell), half)
    c = _Ctx(ng, -740, -1440, fz)
    dist = c.m("SQRT", c.m("ADD", c.m("MULTIPLY", wx, wx), c.m("MULTIPLY", wy, wy)), None, "中心からの距離")
    near = c.m("POWER", c.m("MAXIMUM", c.m("SUBTRACT", 1.0, c.m("DIVIDE", dist, half)), 0.0), 2.0, "近さ z")
    zone = c.m("ADD", c.m("MULTIPLY", near, 3.0), 1.0, "高さ倍率 1〜4")

    merged = None
    if merge:
        # ★v5 となり合う2区画(左=偶数列、右=奇数列)を1棟にする。
        #   道路は8区画ごとに2区画幅なので、(2,3)(4,5)(6,7) の組がちょうど道路をまたがない
        fm = _frame(ng, "⑤ 隣の区画と合体(左を残して右を消す)", (-1300, -1900))
        c = _Ctx(ng, -1080, -1960, fm)
        is_left = c.m("LESS_THAN", c.m("FLOORED_MODULO", gx, 2.0), 0.5, "偶数列=左")
        c = _Ctx(ng, -1080, -2120, fm)
        pair = c.m("ADD", c.m("MULTIPLY", gy, grid_n), c.m("FLOOR", c.m("DIVIDE", gx, 2.0)), "組の番号")
        r_m = _rand(ng, pair, 5, (-400, -2120), fm, "合体の乱数(組ごと)")
        c = _Ctx(ng, -230, -2040, fm)
        p_m = c.m("ADD", c.m("MULTIPLY", near, merge_center), merge_base, "合体する確率")
        hit = c.m("LESS_THAN", r_m, p_m)
        c = _Ctx(ng, 280, -1960, fm)
        merged = c.m("MULTIPLY", c.m("MULTIPLY", hit, is_left), c.m("SUBTRACT", 1.0, is_road), "合体する左")
        right_kill = c.m("MULTIPLY", hit, c.m("SUBTRACT", 1.0, is_left), "消える右")
        kill = _v(_math(ng, "MAXIMUM", kill, right_kill, "消す点(合体込み)", (960, -1900), fm))

    delete = _new_node(ng, "GeometryNodeDeleteGeometry", "建物の点だけ残す", (200, 300))
    delete.domain = "POINT"
    _link(ng, pts[0], pts[1], delete, "Geometry")
    ng.links.new(_sock(kill[0], kill[1], "outputs"), _sock(delete, "Selection"))
    bpts = (delete, "Geometry")

    # ---------------- ③ 建物ごとの値(種類・高さ・色・向き・屋根)
    f3 = _frame(ng, "③ 建物ごとの値を決める", (-1300, -2500))
    if seed_by == "index":
        # p973と同じ作り方:点の通し番号(Index)から乱数を作る。
        # ★建物を1つ消すと、それより後ろの番号が全部ずれる
        idx = _new_node(ng, "GeometryNodeInputIndex", "点の通し番号", (-1250, -2560), f3)
        c = _Ctx(ng, -1080, -2560, f3)
        r_t = c.m("FRACT", c.m("MULTIPLY", c.m("SINE", c.m("MULTIPLY", (idx, "Index"), 78.233)), 43758.547))
        c = _Ctx(ng, -1080, -2720, f3)
        r_h = c.m("FRACT", c.m("MULTIPLY", c.m("SINE", c.m("MULTIPLY", (idx, "Index"), 12.9898)), 43758.547))
    else:
        r_t = _rand(ng, cid, 3, (-1080, -2560), f3, "種類の乱数")
        r_h = _rand(ng, cid, 2, (-1080, -2720), f3, "高さの乱数")
    c = _Ctx(ng, -880, -2560, f3)
    typ = c.m("FLOOR", c.m("MULTIPLY", r_t, 4.0), None, "種類 0〜3")
    is_C = _v(_math(ng, "COMPARE", typ, 2.0, "屋上設備型", (-370, -2640), f3))
    is_D = _v(_math(ng, "COMPARE", typ, 3.0, "低層ワイド型", (-370, -2800), f3))
    for n in (is_C[0], is_D[0]):
        n.inputs[2].default_value = 0.1        # COMPARE の許容差

    if floors:
        # ★v2 階数:端は2〜4階、中心は8〜40階。乱数を2乗して低い建物を多めにする
        c = _Ctx(ng, -880, -2960, f3)
        f_min = c.m("ADD", c.m("MULTIPLY", near, 6.0), 2.0, "最小階")
        c = _Ctx(ng, -880, -3120, f3)
        f_max = c.m("ADD", c.m("MULTIPLY", near, 36.0), 4.0, "最大階")
        c = _Ctx(ng, -540, -3040, f3)
        fl = c.m("MAXIMUM", c.m("ROUND", c.m("ADD", f_min, c.m("MULTIPLY", c.m("SUBTRACT", f_max, f_min),
                                                              c.m("MULTIPLY", r_h, r_h)))), 1.0, "階数")
        # 低層ワイド型は3階まで
        c = _Ctx(ng, 480, -3040, f3)
        capped = c.m("MINIMUM", fl, 3.0)
        fl = c.m("ADD", fl, c.m("MULTIPLY", is_D, c.m("SUBTRACT", capped, fl)), "階数(D型は3階まで)")
        height = c.m("MULTIPLY", fl, FLOOR_H, "高さ=階数×1階ぶん")
    else:
        # p973と同じ:(1〜5) × ゾーニング倍率(1〜4)
        c = _Ctx(ng, -880, -2960, f3)
        height = c.m("MULTIPLY", c.m("ADD", c.m("MULTIPLY", r_h, 4.0), 1.0), zone, "高さ(p973と同じ)")
        fl = c.m("DIVIDE", height, FLOOR_H, "階数の換算(記録用)")

    rot_z = 0.0
    if orient:
        # ★v1 正面(-Y)を、いちばん近い道路へ向ける
        fo = _frame(ng, "①' 正面を近い道路へ向ける", (-1300, -3500))
        c = _Ctx(ng, -1080, -3560, fo)
        dl = c.m("SUBTRACT", bx, road_width - 1.0, "左の道路まで")
        dr = c.m("SUBTRACT", float(road_every), bx, "右の道路まで")
        c = _Ctx(ng, -1080, -3720, fo)
        dd = c.m("SUBTRACT", by, road_width - 1.0, "下の道路まで")
        du = c.m("SUBTRACT", float(road_every), by, "上の道路まで")
        c = _Ctx(ng, -740, -3560, fo)
        dxm = c.m("MINIMUM", dl, dr)
        x_neg = c.m("LESS_THAN", dl, c.m("ADD", dr, 0.01), "左が近い")
        c = _Ctx(ng, -740, -3720, fo)
        dym = c.m("MINIMUM", dd, du)
        y_neg = c.m("LESS_THAN", dd, c.m("ADD", du, 0.01), "下が近い")
        use_x = _v(_math(ng, "LESS_THAN", dxm, dym, "横の道路より縦の道路が近い", (-230, -3640), fo))
        if merged:   # 合体した横長の棟は、長い辺を上下の道路へ向ける
            use_x = _v(_math(ng, "MULTIPLY", use_x, _v(_math(ng, "SUBTRACT", 1.0, merged, None, (-60, -3800), fo)),
                             None, (110, -3640), fo))
        # 角度(度):-Y=0 / +X=90 / +Y=180 / -X=270
        c = _Ctx(ng, -230, -3880, fo)
        ang_x = c.m("ADD", 90.0, c.m("MULTIPLY", x_neg, 180.0))           # 左なら270、右なら90
        ang_y = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, y_neg), 180.0)       # 下なら0、上なら180
        c = _Ctx(ng, 280, -3800, fo)
        ang = c.m("ADD", ang_y, c.m("MULTIPLY", use_x, c.m("SUBTRACT", ang_x, ang_y)), "向き(度)")
        rot_z = c.m("RADIANS", ang)

    col_id = 0.0
    if color:
        # ★v1 色:中心ほど寒色(ガラス・グレー)、外ほど暖色(ベージュ・レンガ)が出やすい
        r_c = _rand(ng, cid, 4, (-1080, -4100), f3, "色の乱数")
        c = _Ctx(ng, -880, -4100, f3)
        mixv = c.m("ADD", c.m("MULTIPLY", r_c, 0.7), c.m("MULTIPLY", c.m("SUBTRACT", 1.0, near), 0.3))
        col_id = c.m("FLOOR", c.m("MULTIPLY", c.m("MINIMUM", mixv, 0.999), float(len(FACADE_COLORS))),
                     None, "色の番号 0〜5")

    roof_id = 0.0
    if roofs:
        # ★v3 屋根:低い建物は切妻、高い建物は段違い(塔屋)が出やすい。屋上設備型は平のまま
        r_r = _rand(ng, cid, 6, (-1080, -4300), f3, "屋根の乱数")
        c = _Ctx(ng, -880, -4300, f3)
        low = c.m("LESS_THAN", fl, 3.5, "3階以下")
        high = c.m("GREATER_THAN", fl, 10.5, "11階以上")
        mid = c.m("SUBTRACT", c.m("SUBTRACT", 1.0, low), high)
        c = _Ctx(ng, -880, -4460, f3)
        p_g = c.m("ADD", c.m("MULTIPLY", low, 0.6), c.m("MULTIPLY", mid, 0.1), "切妻の確率")
        p_s = c.m("ADD", c.m("ADD", c.m("MULTIPLY", low, 0.1), c.m("MULTIPLY", mid, 0.3)),
                  c.m("MULTIPLY", high, 0.5), "段違いの確率")
        c = _Ctx(ng, -880, -4620, f3)
        is_g = c.m("LESS_THAN", r_r, p_g)
        is_s = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, is_g), c.m("LESS_THAN", r_r, c.m("ADD", p_g, p_s)))
        roof_raw = c.m("ADD", is_g, c.m("MULTIPLY", is_s, 2.0))
        roof_id = c.m("MULTIPLY", roof_raw, c.m("SUBTRACT", 1.0, is_C), "屋根 0平/1切妻/2段違い")

    sx = 1.0
    if merged:
        c = _Ctx(ng, -880, -4800, f3)
        sx = c.m("ADD", 1.0, c.m("MULTIPLY", merged, MERGE_SX - 1.0), "横の倍率")
        # 合体した棟は2区画の真ん中へ寄せる
        offv = _new_node(ng, "ShaderNodeCombineXYZ", "半区画ずらす", (-200, -4800), f3)
        _link(ng, _math(ng, "MULTIPLY", merged, cell * 0.5, None, (-370, -4800), f3), "Value", offv, "X")
        sp_m = _new_node(ng, "GeometryNodeSetPosition", "合体した棟を寄せる", (400, 300))
        _link(ng, bpts[0], bpts[1], sp_m, "Geometry")
        _link(ng, offv, "Vector", sp_m, "Offset")
        bpts = (sp_m, "Geometry")

    # 建物ごとの値を点に記録(窓のマテリアルと、そっくり率の集計で使う)
    x0 = 600
    for nm, val in (("type", typ), ("h", height), ("floors", fl), ("col_id", col_id),
                    ("roof", roof_id), ("rot", rot_z), ("sx", sx)):
        bpts = _store(ng, bpts, nm, val, (x0, 300))
        x0 += 170

    # ---------------- ④ 建物を置く
    f4 = _frame(ng, "④ 建物を置く", (2000, 600))
    rot = _new_node(ng, "ShaderNodeCombineXYZ", "回転(Z)", (2050, 100), f4)
    if isinstance(rot_z, tuple):
        ng.links.new(_sock(rot_z[0], rot_z[1], "outputs"), _sock(rot, "Z"))
    scl = _new_node(ng, "ShaderNodeCombineXYZ", "幅・奥行・高さ", (2050, -100), f4)
    _sock(scl, "Y").default_value = 1.0
    if isinstance(sx, tuple):
        ng.links.new(_sock(sx[0], sx[1], "outputs"), _sock(scl, "X"))
    else:
        _sock(scl, "X").default_value = 1.0
    ng.links.new(_sock(height[0], height[1], "outputs"), _sock(scl, "Z"))

    if approach == "baked":
        bcoll = build_baked_collection(mats, baked_n, cell)
        r_b = _rand(ng, cid, 7, (2050, -300), f4, "型の乱数")
        bidx = _math(ng, "FLOOR", _v(_math(ng, "MULTIPLY", r_b, float(baked_n), None, (2220, -300), f4)),
                     None, f"型番号 0〜{baked_n - 1}", (2390, -300), f4)
        ci = _new_node(ng, "GeometryNodeCollectionInfo", f"型{baked_n}種", (2050, 400), f4)
        _sock(ci, "Collection").default_value = bcoll
        _sock(ci, "Separate Children").default_value = True
        _sock(ci, "Reset Children").default_value = True
        pick = (bidx, "Value")
    else:
        bcoll = build_body_collection(mats, cell, orient=orient)
        ci = _new_node(ng, "GeometryNodeCollectionInfo", "本体4種", (2050, 400), f4)
        _sock(ci, "Collection").default_value = bcoll
        _sock(ci, "Separate Children").default_value = True
        _sock(ci, "Reset Children").default_value = True
        pick = typ

    iop = _new_node(ng, "GeometryNodeInstanceOnPoints", "点に建物を置く", (2600, 300), f4)
    _link(ng, bpts[0], bpts[1], iop, "Points")
    _link(ng, ci, "Instances", iop, "Instance")
    _sock(iop, "Pick Instance").default_value = True
    ng.links.new(_sock(pick[0], pick[1], "outputs"), _sock(iop, "Instance Index"))
    _link(ng, rot, "Vector", iop, "Rotation")
    _link(ng, scl, "Vector", iop, "Scale")
    parts = [(iop, "Instances")]

    # ---------------- ⑤ 屋根を載せる(部品方式)
    if roofs and approach == "parts":
        f5 = _frame(ng, "⑤ 屋根の部品を載せる(平は何も置かない)", (2000, -900))
        rcoll = build_roof_collection(mats, cell)
        rci = _new_node(ng, "GeometryNodeCollectionInfo", "屋根2種", (2050, -1000), f5)
        _sock(rci, "Collection").default_value = rcoll
        _sock(rci, "Separate Children").default_value = True
        _sock(rci, "Reset Children").default_value = True
        up = _new_node(ng, "ShaderNodeCombineXYZ", "屋上の高さ", (2050, -1200), f5)
        ng.links.new(_sock(height[0], height[1], "outputs"), _sock(up, "Z"))
        sp_r = _new_node(ng, "GeometryNodeSetPosition", "屋上へ持ち上げる", (2250, -1000), f5)
        _link(ng, bpts[0], bpts[1], sp_r, "Geometry")
        _link(ng, up, "Vector", sp_r, "Offset")
        # 屋上の広さ:A=1 / B=0.69(上の段) / C=1 / D=1.15
        is_B = _math(ng, "COMPARE", typ, 1.0, None, (2050, -1400), f5)
        is_B.inputs[2].default_value = 0.1
        c = _Ctx(ng, 2220, -1400, f5)
        tf = c.m("ADD", c.m("ADD", 1.0, c.m("MULTIPLY", _v(is_B), -0.31)), c.m("MULTIPLY", is_D, 0.15), "屋上の広さ")
        c = _Ctx(ng, 2050, -1560, f5)
        is_gab = c.m("COMPARE", roof_id, 1.0)
        c.ng.nodes[-1].inputs[2].default_value = 0.1
        rz = c.m("ADD", c.m("MULTIPLY", is_gab, GABLE_H - 2 * FLOOR_H), 2 * FLOOR_H, "屋根の高さ")
        rscl = _new_node(ng, "ShaderNodeCombineXYZ", "屋根の倍率", (2900, -1400), f5)
        _link(ng, _math(ng, "MULTIPLY", tf, sx if isinstance(sx, tuple) else 1.0, None, (2730, -1300), f5),
              "Value", rscl, "X")
        ng.links.new(_sock(tf[0], tf[1], "outputs"), _sock(rscl, "Y"))
        ng.links.new(_sock(rz[0], rz[1], "outputs"), _sock(rscl, "Z"))
        has_roof = _math(ng, "GREATER_THAN", roof_id, 0.5, None, (2730, -1100), f5)
        ridx = _math(ng, "SUBTRACT", roof_id, 1.0, None, (2730, -1200), f5)
        iop_r = _new_node(ng, "GeometryNodeInstanceOnPoints", "屋根を置く", (3100, -1000), f5)
        _link(ng, sp_r, "Geometry", iop_r, "Points")
        _link(ng, has_roof, "Value", iop_r, "Selection")
        _link(ng, rci, "Instances", iop_r, "Instance")
        _sock(iop_r, "Pick Instance").default_value = True
        _link(ng, ridx, "Value", iop_r, "Instance Index")
        _link(ng, rot, "Vector", iop_r, "Rotation")
        _link(ng, rscl, "Vector", iop_r, "Scale")
        rgeo = _store(ng, (iop_r, "Instances"), "floors", 2.0, (3300, -1000), f5, domain="INSTANCE")
        rgeo = _store(ng, rgeo, "part", 1.0, (3470, -1000), f5, domain="INSTANCE")
        parts.append(rgeo)

    # ---------------- ⑥ 道路(p973と同じ:アスファルト+中央線、交差点には線を引かない)
    f6 = _frame(ng, "⑥ 道路", (-1300, 1600))
    not_road = _math(ng, "SUBTRACT", 1.0, is_road, "道路以外", (-1250, 1500), f6)
    del_r = _new_node(ng, "GeometryNodeDeleteGeometry", "道路の点だけ残す", (-1080, 1500), f6)
    del_r.domain = "POINT"
    _link(ng, pts[0], pts[1], del_r, "Geometry")
    _link(ng, not_road, "Value", del_r, "Selection")
    tile = _new_node(ng, "GeometryNodeMeshGrid", "路面タイル", (-1080, 1300), f6)
    _sock(tile, "Size X").default_value = cell
    _sock(tile, "Size Y").default_value = cell
    _sock(tile, "Vertices X").default_value = 2
    _sock(tile, "Vertices Y").default_value = 2
    tile_tr = _new_node(ng, "GeometryNodeTransform", "わずかに浮かす", (-910, 1300), f6)
    _link(ng, tile, "Mesh", tile_tr, "Geometry")
    _sock(tile_tr, "Translation").default_value = (0.0, 0.0, 0.04)   # 起伏の段差で地面が透けないよう少し高め
    tile_mat = _new_node(ng, "GeometryNodeSetMaterial", "アスファルト", (-740, 1300), f6)
    _link(ng, tile_tr, "Geometry", tile_mat, "Geometry")
    _sock(tile_mat, "Material").default_value = mats["Road"]
    iop_t = _new_node(ng, "GeometryNodeInstanceOnPoints", "路面を敷く", (-570, 1500), f6)
    _link(ng, del_r, "Geometry", iop_t, "Points")
    _link(ng, tile_mat, "Geometry", iop_t, "Instance")

    # 中央線は2区画幅の道路の「1区画目」にだけ置き、半区画ずらして2区画の境目に引く。
    # ★p973は区画ごとに置いたので1本の道路に線が2本あり、さらに道路の点だけを残した後で
    #   点の通し番号(Index)から縦横を判定していたため、向きと交差点の判定がずれていた
    c = _Ctx(ng, -1250, 1000, f6)
    v_ok = c.m("MULTIPLY", c.m("MULTIPLY", is_col, c.m("LESS_THAN", bx, 0.5)), c.m("SUBTRACT", 1.0, is_row),
               "縦の道路の1区画目")
    c = _Ctx(ng, -1250, 900, f6)
    h_ok = c.m("MULTIPLY", c.m("MULTIPLY", is_row, c.m("LESS_THAN", by, 0.5)), c.m("SUBTRACT", 1.0, is_col),
               "横の道路の1区画目")
    no_line = _math(ng, "LESS_THAN", _v(_math(ng, "ADD", v_ok, h_ok, None, (-400, 950), f6)), 0.5,
                    "線を置かない", (-230, 950), f6)
    del_l0 = _new_node(ng, "GeometryNodeDeleteGeometry", "線を置く点だけ残す", (-60, 1000), f6)
    del_l0.domain = "POINT"
    _link(ng, del_r, "Geometry", del_l0, "Geometry")
    _link(ng, no_line, "Value", del_l0, "Selection")
    l_off = _new_node(ng, "ShaderNodeCombineXYZ", "半区画ずらす", (-60, 800), f6)
    _link(ng, _math(ng, "MULTIPLY", is_col, cell * 0.5, None, (-230, 800), f6), "Value", l_off, "X")
    _link(ng, _math(ng, "MULTIPLY", is_row, cell * 0.5, None, (-230, 700), f6), "Value", l_off, "Y")
    del_l = _new_node(ng, "GeometryNodeSetPosition", "道路の中心へ", (110, 1000), f6)
    _link(ng, del_l0, "Geometry", del_l, "Geometry")
    _link(ng, l_off, "Vector", del_l, "Offset")
    bar = _new_node(ng, "GeometryNodeMeshGrid", "中央線の板", (-1080, 800), f6)
    _sock(bar, "Size X").default_value = cell * 0.7
    _sock(bar, "Size Y").default_value = cell * 0.06
    _sock(bar, "Vertices X").default_value = 2
    _sock(bar, "Vertices Y").default_value = 2
    bar_tr = _new_node(ng, "GeometryNodeTransform", "路面より少し上", (-910, 800), f6)
    _link(ng, bar, "Mesh", bar_tr, "Geometry")
    _sock(bar_tr, "Translation").default_value = (0.0, 0.0, 0.07)
    bar_mat = _new_node(ng, "GeometryNodeSetMaterial", "白線", (-740, 800), f6)
    _link(ng, bar_tr, "Geometry", bar_mat, "Geometry")
    _sock(bar_mat, "Material").default_value = mats["Line"]
    rot90 = _math(ng, "MULTIPLY", is_col, math.pi / 2.0, "縦道路は90度", (-740, 1000), f6)
    rotv = _new_node(ng, "ShaderNodeCombineXYZ", None, (-570, 1000), f6)
    _link(ng, rot90, "Value", rotv, "Z")
    iop_l = _new_node(ng, "GeometryNodeInstanceOnPoints", "中央線を置く", (-400, 1000), f6)
    _link(ng, del_l, "Geometry", iop_l, "Points")
    _link(ng, bar_mat, "Geometry", iop_l, "Instance")
    _link(ng, rotv, "Vector", iop_l, "Rotation")
    for src in ((iop_t, "Instances"), (iop_l, "Instances")):
        parts.append(_store(ng, src, "part", 2.0, (-230, 1500 if src[0] is iop_t else 1000), f6,
                            domain="INSTANCE"))

    # ---------------- 地面
    gmat = _new_node(ng, "GeometryNodeSetMaterial", "地面のマテリアル", (2600, -2200))
    _link(ng, ground[0], ground[1], gmat, "Geometry")
    _sock(gmat, "Material").default_value = mats["Ground"]
    parts.append((gmat, "Geometry"))

    join = _new_node(ng, "GeometryNodeJoinGeometry", "建物+屋根+道路+地面", (4600, 0))
    for src in parts:
        _link(ng, src[0], src[1], join, "Geometry")
    city = (join, "Geometry")

    if realize:
        rzn = _new_node(ng, "GeometryNodeRealizeInstances", "実体化", (4900, 0))
        _link(ng, city[0], city[1], rzn, "Geometry")
        city = (rzn, "Geometry")

    _link(ng, city[0], city[1], g_out, "Geometry")
    tidy_layout(ng)
    return ng



def ref_faces(r, depth=0):
    """分身が指している中身の面の数。
    ★Collection Info で丸ごと置いた分身(v8 の敷地)は、中身が GeometrySet や Collection として返り、
      素朴に .polygons を見ると 0 面になる(Windows での検証で判明・2026-10-07)。中までたどって数える"""
    if depth > 8 or r is None:
        return 0
    if isinstance(r, bpy.types.Object):
        if r.type == "MESH":
            return len(r.data.polygons)
        if r.instance_type == "COLLECTION" and r.instance_collection:
            return ref_faces(r.instance_collection, depth + 1)
        return 0
    if isinstance(r, bpy.types.Collection):
        return sum(ref_faces(o, depth + 1) for o in r.all_objects)
    # GeometrySet
    n = len(r.mesh.polygons) if getattr(r, "mesh", None) else 0
    pc = r.instances_pointcloud() if hasattr(r, "instances_pointcloud") else None
    if pc:
        idx = [0] * len(pc.points)
        pc.attributes[".reference_index"].data.foreach_get("value", idx)
        sub = [ref_faces(x, depth + 1) for x in r.instance_references()]
        n += sum(sub[i] for i in idx if i < len(sub))
    return n

# ---------------------------------------------------------------- シーン組み立て

def set_camera(cam, size, mode="wide", ground_z=None):
    """カメラを決まった位置に置く。段階間で必ず同じ画角になるよう注視点から逆算する。
      wide   … 街全体の俯瞰
      close  … 中心寄りの区画を斜め上から
      detail … 建物数棟に寄る(屋根・窓・色が読める)
      street … 道路の上、人の目線より少し高いところから(ground_z が要る)"""
    from mathutils import Vector
    if mode == "street":
        # 中心から1ブロック外れた縦の道路の上に立ち、道の先を見る
        x = (8 * round(size / 2.0 / 16.0 / 2.0) + 0.5) * 2.0 - size * 0.5
        y = -size * 0.18
        z = (ground_z if ground_z is not None else 0.0) + 0.6
        cam.data.lens = 24
        cam.location = Vector((x, y, z))
        target = Vector((x, y + 30.0, z + 3.5))
        cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
        return cam
    if mode == "wide":
        target = Vector((0.0, 0.0, size * 0.05))
        elev, azim, dist, lens = math.radians(50.0), math.radians(45.0), size * 1.45, 40
    elif mode == "detail":
        target = Vector((size * 0.10, size * 0.10, size * 0.02))
        elev, azim, dist, lens = math.radians(28.0), math.radians(38.0), size * 0.22, 50
    else:
        target = Vector((size * 0.12, size * 0.12, size * 0.03))
        elev, azim, dist, lens = math.radians(42.0), math.radians(42.0), size * 0.40, 40
    cam.data.lens = lens
    cam.location = target + Vector((
        -math.cos(elev) * math.sin(azim) * dist,
        -math.cos(elev) * math.cos(azim) * dist,
        math.sin(elev) * dist,
    ))
    cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
    return cam


def set_lighting(bright=False):
    """寄りのカットだけ明るくする(俯瞰向けの暗い設定だと、寄るとシルエットになる)"""
    sun = bpy.data.objects.get("Sun")
    if sun:
        sun.data.energy = 6.0 if bright else 3.5
    world = bpy.context.scene.world
    bg = world.node_tree.nodes.get("Background") if world and world.use_nodes else None
    if bg:
        bg.inputs[0].default_value = ((0.30, 0.36, 0.45, 1.0) if bright
                                      else (0.10, 0.12, 0.16, 1.0))


def setup_scene(grid_n=81, cell=2.0):
    """カメラ・ライト・空を用意する。段階間で見た目の条件を揃えるため毎回同じ値にする"""
    for ob in list(bpy.data.objects):
        bpy.data.objects.remove(ob, do_unlink=True)
    sc = bpy.context.scene
    sc.render.engine = "BLENDER_EEVEE"
    sc.render.resolution_x, sc.render.resolution_y = 1280, 720
    sc.render.resolution_percentage = 100
    size = cell * (grid_n - 1)

    me = bpy.data.meshes.new("CityMesh")
    city = bpy.data.objects.new("City", me)
    bpy.context.collection.objects.link(city)

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.clip_start = 0.05
    cam_data.clip_end = max(size * 10.0, 1000.0)   # ★既定の100mだと街が外に出て真っ暗
    cam = bpy.data.objects.new("Cam", cam_data)
    bpy.context.collection.objects.link(cam)
    set_camera(cam, size, "wide")
    sc.camera = cam

    sun_data = bpy.data.lights.new("Sun", type="SUN")
    sun_data.energy = 3.5
    sun_data.angle = math.radians(2.0)
    sun = bpy.data.objects.new("Sun", sun_data)
    sun.rotation_euler = (math.radians(50), 0, math.radians(35))
    bpy.context.collection.objects.link(sun)

    world = bpy.data.worlds.get("World") or bpy.data.worlds.new("World")
    world.use_nodes = True
    bg = world.node_tree.nodes.get("Background")
    if bg:
        bg.inputs[0].default_value = (0.10, 0.12, 0.16, 1.0)
        bg.inputs[1].default_value = 1.0
    sc.world = world
    return city, cam


def apply_tree(city_obj, ng):
    for m in list(city_obj.modifiers):
        city_obj.modifiers.remove(m)
    mod = city_obj.modifiers.new("City", "NODES")
    mod.node_group = ng
    return mod


# ---------------------------------------------------------------- 段階の定義

STAGES = {
    # 実験1・2:要素を1つずつ足す
    "v0_p973":    dict(),
    "v1_orient":  dict(orient=True, color=True),
    "v2_floors":  dict(orient=True, color=True, floors=True),
    "v3_roofs":   dict(orient=True, color=True, floors=True, roofs=True),
    "v4_windows": dict(orient=True, color=True, floors=True, roofs=True, windows=True),
    "v5_merge":   dict(orient=True, color=True, floors=True, roofs=True, windows=True, merge=True),
    # 実験3:型の持ち方(見た目は v5 相当の条件で、置き方だけ変える)
    "e3_parts":         dict(orient=True, color=True, floors=True, roofs=True, windows=True, merge=True),
    "e3_parts_realize": dict(orient=True, color=True, floors=True, roofs=True, windows=True, merge=True,
                             realize=True),
    "e3_baked4":   dict(orient=True, color=True, floors=True, windows=True, merge=True,
                        approach="baked", baked_n=4),
    "e3_baked16":  dict(orient=True, color=True, floors=True, windows=True, merge=True,
                        approach="baked", baked_n=16),
    "e3_baked64":  dict(orient=True, color=True, floors=True, windows=True, merge=True,
                        approach="baked", baked_n=64),
    # おまけ:p973と同じ「点の通し番号」で乱数を作ると、合体で街中が入れ替わる
    "x_index_v4": dict(orient=True, color=True, floors=True, roofs=True, windows=True, seed_by="index"),
    "x_index_v5": dict(orient=True, color=True, floors=True, roofs=True, windows=True, merge=True,
                       seed_by="index"),
}


# v6〜v8:用途地域(住宅街・商店街・工業地帯)。v5 の街を組み立てたあと、
# 値を決めている数式ノードの出口を差し替える(zones_v6/v7/v8.py の build)
ZONE_STAGES = {
    "v6_zones": "zones_v6",   # 地区ごとに用途を決め、階数・種類・屋根・色・横幅を差し替える
    "v7_zones": "zones_v7",   # 住宅は街区の外周だけ+すき間、工業は1街区1敷地
    "v8_lots":  "zones_v8",   # 住宅を敷地ごと(庭・駐車場・柵を1部品)に
}


# 最初に使っていた起伏(p973 と同じ細かさで、高さだけ半分)。道路が波打つ比較用
TERRAIN_OLD = dict(terrain_amp=3.0, terrain_scale=1.6, terrain_detail=2.0)


def build_stage(stage, grid_n=81, cell=2.0, **over):
    """段階の名前から街を組み立てる。over で build_city_tree の引数を上書きできる(例: **TERRAIN_OLD)"""
    if stage in ZONE_STAGES:
        import importlib
        mod = importlib.import_module(ZONE_STAGES[stage])
        mod.GRID, mod.CELL = grid_n, cell
        city, ng = build_stage("v5_merge", grid_n=grid_n, cell=cell, **over)
        mod.build(ng)
        tidy_layout(ng)
        return city, ng
    opts = dict(STAGES[stage])
    opts.update(over)
    city, cam = setup_scene(grid_n=grid_n, cell=cell)
    ng = build_city_tree(grid_n=grid_n, cell=cell, **opts)
    apply_tree(city, ng)
    return city, ng
zones_v6.py … 用途地域:値の決め方を用途ごとに差し替える・297行
# -*- coding: utf-8 -*-
"""p1002 追加: 用途地域(住宅街・商店街・工業地帯)で区画を作り分ける。

★city_var.py の作りは変えずに、組み上がったツリーへ足す。
  v5_merge の街を組み立てたあと、**できあがったノードツリーに枠を1つ足して、
  値を決めている数式ノードの出口だけを差し替える**。

  blender.exe -b --factory-startup --python zones_v6.py -- --grid 161 --out out/extra_zones --render

やっていること:
  ① 地区の番号(40区画=80m四方ごと)から用途を決める … 0=住宅 / 1=商店 / 2=工業
  ② 階数・種類・屋根・色・横幅を、用途ごとの値に差し替える
  ③ 用途を属性 zone として点に記録する(スプレッドシートや集計で見える)
"""
import bpy
import json
import math
import os
import sys
from collections import Counter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cv  # noqa: E402

# ※2026-10-07 単体実行に加えて、city_var.build_stage("v6_…") から build(ng) を呼べるようにした。
#   起伏をなだらかにする処理(smooth_terrain)は city_var.py の既定に移したので、ここでは呼ばない。

A = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []


def arg(n, d):
    return A[A.index("--" + n) + 1] if "--" + n in A else d


GRID = int(arg("grid", 161))
CELL = float(arg("cell", 2.0))
OUT = arg("out", "out/extra_zones")
DISTRICT = float(arg("district", 40))     # 何区画ごとに用途を変えるか(40区画=80m)
LAYOUT = arg("layout", "ring")            # "ring"=中心が商店街・外縁が工業 / "random"=地区ごとのくじ引き
RENDER = "--render" in A
SAVE = "--save" in A
ZONE_NAMES = {0: "住宅", 1: "商店", 2: "工業"}


def log(*a):
    print("[zones]", *a, flush=True)


# ---------------------------------------------------------------- ノードの差し替え

def find(ng, label):
    n = next((x for x in ng.nodes if x.label == label), None)
    if n is None:
        raise RuntimeError(f"ラベル『{label}』のノードが見つからない")
    return n


def consumers(ng, node, sock="Value"):
    """このノードの出力を使っている先を控えておく(差し替え前に取る)。

    ★`link.to_node is node` は使えない。Blenderは参照のたびに別のPythonオブジェクトを返すので
      `is` が必ず偽になり、接続が1つも見つからないまま静かに通ってしまう。名前で突き合わせる。
    """
    return [(lk.to_node.name, lk.to_socket.identifier) for lk in ng.links
            if lk.from_node.name == node.name and lk.from_socket.name == sock]


def rewire(ng, targets, new_out):
    for node_name, sock_id in targets:
        to_node = ng.nodes[node_name]
        to_sock = next(s for s in to_node.inputs if s.identifier == sock_id)
        ng.links.new(cv._sock(new_out[0], new_out[1], "outputs"), to_sock)


def pick3(c, is1, is2, v0, v1, v2, label=None):
    """用途で3つの値を選ぶ: v0 + (用途1)*(v1-v0) + (用途2)*(v2-v0)"""
    t = c.m("ADD", v0, c.m("MULTIPLY", is1, c.m("SUBTRACT", v1, v0)))
    return c.m("ADD", t, c.m("MULTIPLY", is2, c.m("SUBTRACT", v2, v0)), label)


def add_zones(ng):
    f = cv._frame(ng, "⑥ 用途地域(0住宅 / 1商店 / 2工業)", (-2300, -5200))

    gx = cv._named(ng, "gx", (-2250, -5300), f)
    gy = cv._named(ng, "gy", (-2250, -5460), f)
    c = cv._Ctx(ng, -2060, -5300, f)
    dx = c.m("FLOOR", c.m("DIVIDE", gx, DISTRICT), None, "地区の列")
    c = cv._Ctx(ng, -2060, -5460, f)
    dy = c.m("FLOOR", c.m("DIVIDE", gy, DISTRICT), None, "地区の行")
    c = cv._Ctx(ng, -1720, -5380, f)
    did = c.m("ADD", c.m("MULTIPLY", dy, 100.0), dx, "地区の番号")
    r_z = cv._rand(ng, did, 11, (-1380, -5380), f, "用途の乱数")

    # 既にある値(乱数・近さ)を再利用する
    r_h = cv._v(find(ng, "高さの乱数"))
    r_t = cv._v(find(ng, "種類の乱数"))
    r_c = cv._v(find(ng, "色の乱数"))
    r_r = cv._v(find(ng, "屋根の乱数"))
    near = cv._v(find(ng, "近さ z"))

    c = cv._Ctx(ng, -1210, -5380, f)
    if LAYOUT == "random":
        # 地区ごとに用途をくじ引きする(まだら模様になる)
        zone = c.m("FLOOR", c.m("MULTIPLY", r_z, 3.0), None, "用途 0住宅/1商店/2工業")
        is1 = c.m("COMPARE", zone, 1.0, "商店か")
        is1[0].inputs[2].default_value = 0.1
        is2 = c.m("COMPARE", zone, 2.0, "工業か")
        is2[0].inputs[2].default_value = 0.1
    else:
        # ★現実の街に寄せた並び: 中心=商店街 / その外側=住宅街 / 外縁=工業地帯。
        #   中心からの「四角い距離」u(0=中心〜1=端)で分ける。街が四角いので、円より面積配分が素直。
        #   境目が定規で引いたようにならないよう、地区ごとの乱数で±0.06ゆらす
        half_n = (GRID - 1) * 0.5
        cx = c.m("ABSOLUTE", c.m("SUBTRACT", gx, half_n))
        c2 = cv._Ctx(ng, -1210, -5540, f)
        cy = c2.m("ABSOLUTE", c2.m("SUBTRACT", gy, half_n))
        c3 = cv._Ctx(ng, -870, -5460, f)
        u = c3.m("DIVIDE", c3.m("MAXIMUM", cx, cy), half_n, "中心からの四角い距離 0〜1")
        u2 = c3.m("ADD", u, c3.m("MULTIPLY", c3.m("SUBTRACT", r_z, 0.5), 0.12), "ゆらぎ込み")
        c4 = cv._Ctx(ng, -360, -5380, f)
        is1 = c4.m("LESS_THAN", u2, 0.30, "商店か(中心側)")
        c5 = cv._Ctx(ng, -360, -5540, f)
        is2 = c5.m("MULTIPLY", c5.m("GREATER_THAN", u2, 0.82), c5.m("SUBTRACT", 1.0, is1), "工業か(外縁)")
        c = cv._Ctx(ng, -360, -5700, f)
        c6 = cv._Ctx(ng, -40, -5460, f)
        zone = c6.m("ADD", is1, c6.m("MULTIPLY", is2, 2.0), "用途 0住宅/1商店/2工業")

    # ---- ① 階数:住宅1〜3階 / 商店3階〜(中心ほど高い) / 工業1〜2階
    fl_node = find(ng, "階数(D型は3階まで)")
    fl_used = consumers(ng, fl_node)
    c = cv._Ctx(ng, -1210, -5620, f)
    fl_home = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 3.0)), "住宅 1〜3階")
    c = cv._Ctx(ng, -1210, -5820, f)
    fl_shop = c.m("ADD", 3.0, c.m("FLOOR", c.m("MULTIPLY", r_h, c.m("ADD", 3.0, c.m("MULTIPLY", near, 12.0)))),
                  "商店 3階〜(中心ほど高い)")
    c = cv._Ctx(ng, -1210, -6020, f)
    fl_ind = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 2.0)), "工業 1〜2階")
    c = cv._Ctx(ng, -400, -5720, f)
    fl_new = pick3(c, is1, is2, fl_home, fl_shop, fl_ind, "階数(用途ごと)")
    rewire(ng, fl_used, fl_new)

    # ---- ② 種類:住宅=箱/低層ワイド、商店=セットバック/屋上設備、工業=低層ワイド
    typ_node = find(ng, "種類 0〜3")
    typ_used = consumers(ng, typ_node)
    c = cv._Ctx(ng, -1210, -6220, f)
    t_home = c.m("MULTIPLY", c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), 3.0, "住宅 0箱/3低層ワイド")
    c = cv._Ctx(ng, -1210, -6420, f)
    t_shop = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), "商店 1セットバック/2屋上設備")
    c = cv._Ctx(ng, -400, -6320, f)
    t_new = pick3(c, is1, is2, t_home, t_shop, 3.0, "種類(用途ごと)")
    rewire(ng, typ_used, t_new)

    # ---- ③ 屋根:住宅は切妻が多い、商店・工業は平
    roof_node = find(ng, "屋根 0平/1切妻/2段違い")
    roof_used = consumers(ng, roof_node)
    is_C = cv._v(find(ng, "屋上設備型"))
    c = cv._Ctx(ng, -1210, -6620, f)
    r_home = c.m("LESS_THAN", r_r, 0.75, "住宅 切妻75%")
    c = cv._Ctx(ng, -400, -6620, f)
    roof_new = pick3(c, is1, is2, r_home, 0.0, 0.0)
    roof_new = c.m("MULTIPLY", roof_new, c.m("SUBTRACT", 1.0, is_C), "屋根(用途ごと)")
    rewire(ng, roof_used, roof_new)

    # ---- ④ 色:住宅=白っぽい/ベージュ、商店=青グレー/グレー、工業=グレー/こげ茶
    col_node = find(ng, "色の番号 0〜5")
    col_used = consumers(ng, col_node)
    c = cv._Ctx(ng, -1210, -6820, f)
    two = c.m("FLOOR", c.m("MULTIPLY", r_c, 2.0), None, "0か1")
    c = cv._Ctx(ng, -870, -6820, f)
    col_home = c.m("ADD", 2.0, two, "住宅 2白/3ベージュ")
    c = cv._Ctx(ng, -870, -6980, f)
    col_ind = c.m("ADD", 1.0, c.m("MULTIPLY", two, 4.0), "工業 1グレー/5こげ茶")
    c = cv._Ctx(ng, -400, -6900, f)
    col_new = pick3(c, is1, is2, col_home, two, col_ind, "色(用途ごと)")
    rewire(ng, col_used, col_new)

    # ---- ⑤ 横幅:工業だけ横に広い(倉庫・工場のイメージ)
    sx_node = next((x for x in ng.nodes if x.label == "横の倍率"), None)
    if sx_node is not None:
        sx_used = consumers(ng, sx_node)
        c = cv._Ctx(ng, -400, -7100, f)
        sx_new = c.m("MULTIPLY", cv._v(sx_node), c.m("ADD", 1.0, c.m("MULTIPLY", is2, 0.6)), "横の倍率(工業は広い)")
        rewire(ng, sx_used, sx_new)

    # ---- ⑥ 用途を点に記録する(建物を置く直前に差し込む)
    iop = find(ng, "点に建物を置く")
    src = next(lk for lk in ng.links if lk.to_node.name == iop.name and lk.to_socket.name == "Points")
    geo = (ng.nodes[src.from_node.name], src.from_socket.name)
    new_geo = cv._store(ng, geo, "zone", zone, (1800, 300))
    ng.links.new(cv._sock(new_geo[0], new_geo[1], "outputs"), cv._sock(iop, "Points"))
    return zone


# ---------------------------------------------------------------- 集計・撮影

def read_rows(city):
    dg = bpy.context.evaluated_depsgraph_get()
    dg.update()
    gs = city.evaluated_get(dg).evaluated_geometry()
    pc = gs.instances_pointcloud()
    if not pc:
        return []
    n = len(pc.points)
    cols = {}
    for name in ("gx", "gy", "part", "type", "h", "floors", "col_id", "roof", "sx", "zone"):
        a = pc.attributes.get(name)
        v = [0.0] * n
        if a is not None:
            a.data.foreach_get("value", v)
        cols[name] = v
    return [{k: cols[k][i] for k in cols} for i in range(n)]


def ground_z_at(x, y):
    dg = bpy.context.evaluated_depsgraph_get()
    hit, loc, *_ = bpy.context.scene.ray_cast(dg, (x, y, 500.0), (0.0, 0.0, -1.0))
    return loc.z if hit else 0.0


def render_views(outdir, views=("wide", "close", "street"), res=(1600, 900), samples=64):
    sc = bpy.context.scene
    sc.render.resolution_x, sc.render.resolution_y = res
    sc.render.image_settings.file_format = "PNG"
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    shots = os.path.join(outdir, "shots")
    os.makedirs(shots, exist_ok=True)
    size = CELL * (GRID - 1)
    cam = sc.camera
    names = []
    for mode in views:
        gz = None
        if mode == "street":
            cv.set_camera(cam, size, "street", 0.0)
            gz = ground_z_at(cam.location.x, cam.location.y)
        cv.set_camera(cam, size, mode, gz)
        cv.set_lighting(mode in ("detail", "street"))
        sc.render.filepath = os.path.join(shots, f"v6_zones_n{GRID}_{mode}.png")
        bpy.ops.render.render(write_still=True)
        names.append(os.path.basename(sc.render.filepath))
        log("  shot", names[-1])
    cv.set_camera(cam, size, "wide")
    cv.set_lighting(False)
    return names


def main():
    here = os.path.dirname(os.path.abspath(__file__))
    outdir = OUT if os.path.isabs(OUT) else os.path.join(here, OUT)
    os.makedirs(outdir, exist_ok=True)

    city, ng = cv.build_stage("v5_merge", grid_n=GRID, cell=CELL)
    n_before = len(ng.nodes)
    add_zones(ng)
    log("ノード数", n_before, "→", len(ng.nodes))

    rows = [r for r in read_rows(city) if r["part"] < 0.5]
    rec = {"grid": GRID, "cell": CELL, "district_cells": DISTRICT,
           "blender": bpy.app.version_string, "buildings": len(rows), "zones": {}}
    for z, name in ZONE_NAMES.items():
        g = [r for r in rows if int(round(r["zone"])) == z]
        if not g:
            continue
        rec["zones"][name] = {
            "棟数": len(g),
            "割合": round(len(g) / len(rows), 3),
            "階数": {"最小": int(min(r["floors"] for r in g)), "最大": int(max(r["floors"] for r in g)),
                     "平均": round(sum(r["floors"] for r in g) / len(g), 2)},
            "種類": dict(Counter(int(r["type"]) for r in g)),
            "屋根": dict(Counter(int(r["roof"]) for r in g)),
            "色": dict(Counter(int(r["col_id"]) for r in g)),
            "合体した棟": sum(1 for r in g if r["sx"] > 1.5),
        }
        log(f"{name}: {len(g)}棟 平均{rec['zones'][name]['階数']['平均']}階 "
            f"種類{rec['zones'][name]['種類']} 屋根{rec['zones'][name]['屋根']}")

    if RENDER:
        rec["shots"] = render_views(outdir)
    if SAVE:
        bpy.ops.wm.save_as_mainfile(filepath=os.path.join(outdir, f"p1002_v6_zones_n{GRID}.blend"))

    with open(os.path.join(outdir, f"zones_n{GRID}.json"), "w", encoding="utf-8") as f:
        json.dump(rec, f, ensure_ascii=False, indent=2)
    with open(os.path.join(outdir, f"rows_v6_zones_n{GRID}.json"), "w", encoding="utf-8") as f:
        json.dump(rows, f)
    log("DONE", os.path.join(outdir, f"zones_n{GRID}.json"))


if __name__ == "__main__":   # run_stages / measure から部品として読み込むときは走らせない
    main()


def build(ng):
    """city_var.build_stage から呼ぶ入口(v7・v8 と名前をそろえる)"""
    return add_zones(ng)
zones_v7.py … 用途地域:住宅は外周だけ、工業は1街区2棟・423行
# -*- coding: utf-8 -*-
"""p1002 追加その2: 用途地域をもっと自然な形にする(v7)。

★city_var.py の作りは変えずに、組み上がったツリーへ足す。
  v5_merge のノードツリーに枠を足し、値を決めている数式ノードの出口だけ差し替える。

  blender.exe -b --factory-startup --python zones_v7.py -- --grid 161 --out out/extra_zones2 --render

v6(zones_v6.py)との違い:
  住宅街 … 街区の**外周だけ**に家を建て、家は0.72倍に縮めて**家どうしのすき間**をつくる。
           街区の内側は**庭(緑)と駐車場(灰)**の敷地を敷く
  工業地帯 … 街区まるごとを1つの敷地にして、**生産棟(4区画×3区画の大きな建物)**と
           **オフィス棟(3〜4階)**を1つずつ建て、残りは**舗装したヤード**にする
  商店街 … v6のまま(街区いっぱいに建てる。合体もここだけ)
"""
import bpy
import json
import os
import sys
from collections import Counter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cv  # noqa: E402

# ※2026-10-07 単体実行に加えて、city_var.build_stage("v7_…") から build(ng) を呼べるようにした。
#   起伏をなだらかにする処理(smooth_terrain)は city_var.py の既定に移したので、ここでは呼ばない。

A = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []


def arg(n, d):
    return A[A.index("--" + n) + 1] if "--" + n in A else d


GRID = int(arg("grid", 161))
CELL = float(arg("cell", 2.0))
OUT = arg("out", "out/extra_zones2")
RENDER = "--render" in A
SAVE = "--save" in A
ROAD_EVERY, ROAD_W = 8, 2            # 原本の既定値(8区画ごとに2区画幅の道路)
HOME_SCALE = 0.72                    # 住宅の大きさ(1.0=区画いっぱい)。すき間が庭・駐車場になる
SHED_SX, SHED_SY = 4.6, 3.4          # 生産棟の大きさ(区画4×3ぶん)
ZONE_NAMES = {0: "住宅", 1: "商店", 2: "工業"}


def log(*a):
    print("[zones2]", *a, flush=True)


# ---------------------------------------------------------------- 差し替えの道具

def find(ng, label, required=True):
    n = next((x for x in ng.nodes if x.label == label), None)
    if n is None and required:
        raise RuntimeError(f"ラベル『{label}』のノードが見つからない")
    return n


def consumers(ng, node, sock="Value"):
    """★`link.to_node is node` は使えない(Blenderは参照のたびに別オブジェクトを返す)。名前で突き合わせる"""
    return [(lk.to_node.name, lk.to_socket.identifier) for lk in ng.links
            if lk.from_node.name == node.name and lk.from_socket.name == sock]


def rewire(ng, targets, new_out):
    for node_name, sock_id in targets:
        to_node = ng.nodes[node_name]
        to_sock = next(s for s in to_node.inputs if s.identifier == sock_id)
        ng.links.new(cv._sock(new_out[0], new_out[1], "outputs"), to_sock)


def link_to(ng, out, node, sock_name):
    ng.links.new(cv._sock(out[0], out[1], "outputs"), cv._sock(node, sock_name))


def eq(c, a, b, label=None):
    v = c.m("COMPARE", a, b, label)
    v[0].inputs[2].default_value = 0.1
    return v


def mat(name, rgb, rough=0.95):
    m = bpy.data.materials.get(name)
    if m is None:
        m = bpy.data.materials.new(name)
    m.use_nodes = True
    bsdf = m.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = rgb
        if "Roughness" in bsdf.inputs:
            bsdf.inputs["Roughness"].default_value = rough
    m.diffuse_color = rgb
    return m


def ground_patch(ng, pts_geo, keep, material, frame, loc, z=0.03):
    """区画に薄い板を敷く(道路の作り方と同じやり方)。keep が1の点にだけ敷く"""
    x, y = loc
    drop = cv._new_node(ng, "GeometryNodeDeleteGeometry", "敷く点だけ残す", (x, y), frame)
    drop.domain = "POINT"
    cv._link(ng, pts_geo[0], pts_geo[1], drop, "Geometry")
    inv = cv._math(ng, "SUBTRACT", 1.0, keep, "敷かない点", (x - 170, y), frame)
    cv._link(ng, inv, "Value", drop, "Selection")
    tile = cv._new_node(ng, "GeometryNodeMeshGrid", "敷地の板", (x, y - 200), frame)
    cv._sock(tile, "Size X").default_value = CELL * 0.96
    cv._sock(tile, "Size Y").default_value = CELL * 0.96
    cv._sock(tile, "Vertices X").default_value = 2
    cv._sock(tile, "Vertices Y").default_value = 2
    tr = cv._new_node(ng, "GeometryNodeTransform", "わずかに浮かす", (x + 170, y - 200), frame)
    cv._link(ng, tile, "Mesh", tr, "Geometry")
    cv._sock(tr, "Translation").default_value = (0.0, 0.0, z)
    sm = cv._new_node(ng, "GeometryNodeSetMaterial", material.name, (x + 340, y - 200), frame)
    cv._link(ng, tr, "Geometry", sm, "Geometry")
    cv._sock(sm, "Material").default_value = material
    iop = cv._new_node(ng, "GeometryNodeInstanceOnPoints", "敷地を敷く", (x + 510, y), frame)
    cv._link(ng, drop, "Geometry", iop, "Points")
    cv._link(ng, sm, "Geometry", iop, "Instance")
    geo = cv._store(ng, (iop, "Instances"), "part", 3.0, (x + 680, y), frame, domain="INSTANCE")
    return geo


# ---------------------------------------------------------------- 本体

def build(ng):
    # ★ノードのラベルは64バイト(日本語で約21文字)まで。長いと黙って途中で切れ、名前で探せなくなる
    f = cv._frame(ng, "⑦ 用途地域(住宅=外周 / 工業=2棟)", (-2300, -5200))

    gx = cv._named(ng, "gx", (-2250, -5300), f)
    gy = cv._named(ng, "gy", (-2250, -5460), f)
    r_h = cv._v(find(ng, "高さの乱数"))
    r_t = cv._v(find(ng, "種類の乱数"))
    r_c = cv._v(find(ng, "色の乱数"))
    r_r = cv._v(find(ng, "屋根の乱数"))
    bx = cv._v(find(ng, "ブロック内の列"))
    by = cv._v(find(ng, "ブロック内の行"))
    near = cv._v(find(ng, "近さ z"))

    # ---- 地区ごとの用途(中心=商店街 / 外側=住宅街 / 外縁=工業地帯)
    c = cv._Ctx(ng, -2060, -5300, f)
    dx = c.m("FLOOR", c.m("DIVIDE", gx, 40.0), None, "地区の列")
    c = cv._Ctx(ng, -2060, -5460, f)
    dy = c.m("FLOOR", c.m("DIVIDE", gy, 40.0), None, "地区の行")
    c = cv._Ctx(ng, -1720, -5380, f)
    did = c.m("ADD", c.m("MULTIPLY", dy, 100.0), dx, "地区の番号")
    r_z = cv._rand(ng, did, 11, (-1380, -5380), f, "用途の乱数")

    half_n = (GRID - 1) * 0.5
    c = cv._Ctx(ng, -1210, -5300, f)
    cx = c.m("ABSOLUTE", c.m("SUBTRACT", gx, half_n))
    c2 = cv._Ctx(ng, -1210, -5460, f)
    cy = c2.m("ABSOLUTE", c2.m("SUBTRACT", gy, half_n))
    c3 = cv._Ctx(ng, -870, -5380, f)
    u = c3.m("DIVIDE", c3.m("MAXIMUM", cx, cy), half_n, "中心からの四角い距離")
    u2 = c3.m("ADD", u, c3.m("MULTIPLY", c3.m("SUBTRACT", r_z, 0.5), 0.12), "ゆらぎ込み")
    c4 = cv._Ctx(ng, -360, -5300, f)
    is_shop = c4.m("LESS_THAN", u2, 0.30, "商店か")
    c5 = cv._Ctx(ng, -360, -5460, f)
    is_ind = c5.m("MULTIPLY", c5.m("GREATER_THAN", u2, 0.82), c5.m("SUBTRACT", 1.0, is_shop), "工業か")
    c6 = cv._Ctx(ng, -360, -5620, f)
    is_home = c6.m("SUBTRACT", c6.m("SUBTRACT", 1.0, is_shop), is_ind, "住宅か")
    zone = c6.m("ADD", is_shop, c6.m("MULTIPLY", is_ind, 2.0), "用途 0住宅/1商店/2工業")

    # ---- 街区の中での位置(道路は bx<2 / by<2 の2区画ぶん、内側は 2〜7)
    c = cv._Ctx(ng, -1210, -5800, f)
    on_x = c.m("MAXIMUM", eq(c, bx, float(ROAD_W)), eq(c, bx, float(ROAD_EVERY - 1)), "左右の端")
    c = cv._Ctx(ng, -1210, -5960, f)
    on_y = c.m("MAXIMUM", eq(c, by, float(ROAD_W)), eq(c, by, float(ROAD_EVERY - 1)), "上下の端")
    c = cv._Ctx(ng, -700, -5880, f)
    peri = c.m("MAXIMUM", on_x, on_y, "街区の外周(道路に面する)")
    c = cv._Ctx(ng, -1210, -6120, f)
    shed = c.m("MULTIPLY", eq(c, bx, 3.0), eq(c, by, 3.0), "生産棟を置く区画")
    c = cv._Ctx(ng, -1210, -6280, f)
    office = c.m("MULTIPLY", eq(c, bx, 6.0), eq(c, by, 2.0), "オフィス棟を置く区画")

    # ---- 合体(v5)は商店街だけにする
    p_m = find(ng, "合体する確率", required=False)
    if p_m is not None:
        used = consumers(ng, p_m)
        c = cv._Ctx(ng, -360, -6400, f)
        rewire(ng, used, c.m("MULTIPLY", cv._v(p_m), is_shop, "合体は商店街だけ"))

    # ---- 建てない区画を増やす(住宅=外周以外 / 工業=生産棟とオフィス棟以外)
    kill = find(ng, "消す点(合体込み)", required=False) or find(ng, "消す点")
    kill_used = consumers(ng, kill)
    c = cv._Ctx(ng, -360, -6560, f)
    drop_home = c.m("MULTIPLY", is_home, c.m("SUBTRACT", 1.0, peri), "住宅: 内側は建てない")
    c = cv._Ctx(ng, -360, -6720, f)
    keep_ind = c.m("MAXIMUM", shed, office)
    drop_ind = c.m("MULTIPLY", is_ind, c.m("SUBTRACT", 1.0, keep_ind), "工業: 2棟以外は建てない")
    c = cv._Ctx(ng, 320, -6640, f)
    kill_new = c.m("MAXIMUM", cv._v(kill), c.m("MAXIMUM", drop_home, drop_ind), "消す点(用途ごと)")
    rewire(ng, kill_used, kill_new)

    # ---- 階数
    fl_node = find(ng, "階数(D型は3階まで)")
    fl_used = consumers(ng, fl_node)
    c = cv._Ctx(ng, -1210, -6900, f)
    fl_home = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 3.0)), "住宅 1〜3階")
    c = cv._Ctx(ng, -1210, -7060, f)
    fl_shop = c.m("ADD", 3.0, c.m("FLOOR", c.m("MULTIPLY", r_h, c.m("ADD", 3.0, c.m("MULTIPLY", near, 12.0)))),
                  "商店 3階〜")
    c = cv._Ctx(ng, -1210, -7220, f)
    fl_off = c.m("ADD", 3.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 2.0)), "オフィス棟 3〜4階")
    c = cv._Ctx(ng, -700, -7140, f)
    fl_ind = c.m("ADD", 2.0, c.m("MULTIPLY", office, c.m("SUBTRACT", fl_off, 2.0)), "工業(生産棟2階/オフィス棟)")
    c = cv._Ctx(ng, -190, -7060, f)
    fl_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, fl_home), c.m("MULTIPLY", is_shop, fl_shop)),
                 c.m("MULTIPLY", is_ind, fl_ind), "階数(用途ごと)")
    rewire(ng, fl_used, fl_new)

    # ---- 種類(0箱 / 1セットバック / 2屋上設備 / 3低層ワイド)
    typ_node = find(ng, "種類 0〜3")
    typ_used = consumers(ng, typ_node)
    c = cv._Ctx(ng, -1210, -7400, f)
    t_home = c.m("MULTIPLY", c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), 3.0, "住宅 0箱/3低層ワイド")
    c = cv._Ctx(ng, -1210, -7560, f)
    t_shop = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), "商店 1/2")
    c = cv._Ctx(ng, -700, -7480, f)
    t_ind = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, office), 3.0, "工業 生産棟3低層ワイド/オフィス0箱")
    c = cv._Ctx(ng, -190, -7400, f)
    t_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, t_home), c.m("MULTIPLY", is_shop, t_shop)),
                c.m("MULTIPLY", is_ind, t_ind), "種類(用途ごと)")
    rewire(ng, typ_used, t_new)

    # ---- 屋根(住宅だけ切妻が多い)
    roof_node = find(ng, "屋根 0平/1切妻/2段違い")
    roof_used = consumers(ng, roof_node)
    is_C = cv._v(find(ng, "屋上設備型"))
    c = cv._Ctx(ng, -1210, -7720, f)
    r_home = c.m("LESS_THAN", r_r, 0.75, "住宅 切妻75%")
    c = cv._Ctx(ng, -700, -7720, f)
    roof_new = c.m("MULTIPLY", c.m("MULTIPLY", is_home, r_home), c.m("SUBTRACT", 1.0, is_C), "屋根(用途ごと)")
    rewire(ng, roof_used, roof_new)

    # ---- 色
    col_node = find(ng, "色の番号 0〜5")
    col_used = consumers(ng, col_node)
    c = cv._Ctx(ng, -1210, -7880, f)
    two = c.m("FLOOR", c.m("MULTIPLY", r_c, 2.0), None, "0か1")
    c = cv._Ctx(ng, -870, -7880, f)
    col_home = c.m("ADD", 2.0, two, "住宅 2白/3ベージュ")
    c = cv._Ctx(ng, -870, -8040, f)
    col_ind = c.m("ADD", 1.0, c.m("MULTIPLY", office, -1.0), "工業 生産棟1グレー/オフィス0青灰")
    c = cv._Ctx(ng, -360, -7960, f)
    col_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, col_home), c.m("MULTIPLY", is_shop, two)),
                  c.m("MULTIPLY", is_ind, col_ind), "色(用途ごと)")
    rewire(ng, col_used, col_new)

    # ---- 大きさ(幅・奥行)
    sx_node = find(ng, "横の倍率", required=False)
    scl = find(ng, "幅・奥行・高さ")
    # ★差し替え先は「新しいノードを作る前」に控える。あとで取ると自分自身が混ざって循環参照になる
    sx_used = consumers(ng, sx_node) if sx_node is not None else []
    base_sx = cv._v(sx_node) if sx_node is not None else 1.0
    c = cv._Ctx(ng, -190, -8200, f)
    sx_ind = c.m("ADD", 1.0, c.m("MULTIPLY", shed, SHED_SX - 1.0), "工業の幅(生産棟は4区画ぶん)")
    c = cv._Ctx(ng, -190, -8360, f)
    sy_ind = c.m("ADD", 1.0, c.m("MULTIPLY", shed, SHED_SY - 1.0), "工業の奥行(生産棟は3区画ぶん)")
    c = cv._Ctx(ng, 320, -8200, f)
    sx_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, HOME_SCALE), c.m("MULTIPLY", is_shop, base_sx)),
                 c.m("MULTIPLY", is_ind, sx_ind), "幅(用途ごと)")
    c = cv._Ctx(ng, 320, -8360, f)
    sy_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, HOME_SCALE), is_shop),
                 c.m("MULTIPLY", is_ind, sy_ind), "奥行(用途ごと)")
    if sx_node is not None:
        rewire(ng, sx_used, sx_new)                     # 幅の記録と屋根の幅もここで切り替わる
    else:
        link_to(ng, sx_new, scl, "X")
    link_to(ng, sy_new, scl, "Y")

    # 屋根の奥行も同じ倍率にする(本体だけ縮むと屋根がはみ出す)
    rscl = find(ng, "屋根の倍率", required=False)
    if rscl is not None:
        tf = cv._v(find(ng, "屋上の広さ"))
        c = cv._Ctx(ng, 830, -8360, f)
        link_to(ng, c.m("MULTIPLY", tf, sy_new, "屋根の奥行"), rscl, "Y")

    # ---- 生産棟は4×3区画の真ん中へ寄せる
    iop = find(ng, "点に建物を置く")
    src = next(lk for lk in ng.links if lk.to_node.name == iop.name and lk.to_socket.name == "Points")
    geo = (ng.nodes[src.from_node.name], src.from_socket.name)
    geo = cv._store(ng, geo, "zone", zone, (1700, 300))
    geo = cv._store(ng, geo, "sy", sy_new, (1870, 300))
    off = cv._new_node(ng, "ShaderNodeCombineXYZ", "生産棟を敷地の中心へ", (2040, 140))
    c = cv._Ctx(ng, 1870, 140, f)
    link_to(ng, c.m("MULTIPLY", shed, CELL * 0.5), off, "X")
    sp = cv._new_node(ng, "GeometryNodeSetPosition", "生産棟を寄せる", (2210, 300))
    cv._link(ng, geo[0], geo[1], sp, "Geometry")
    cv._link(ng, off, "Vector", sp, "Offset")
    ng.links.new(cv._sock(sp, "Geometry", "outputs"), cv._sock(iop, "Points"))

    # ---- 庭・駐車場・ヤードの敷地を敷く
    del_r = find(ng, "道路の点だけ残す")
    psrc = next(lk for lk in ng.links if lk.to_node.name == del_r.name and lk.to_socket.name == "Geometry")
    pts_geo = (ng.nodes[psrc.from_node.name], psrc.from_socket.name)
    is_road = cv._v(find(ng, "どちらかが道路"))
    f8 = cv._frame(ng, "⑧ 庭・駐車場・工場のヤード", (1700, -2600))
    c = cv._Ctx(ng, 1750, -2700, f8)
    not_road = c.m("SUBTRACT", 1.0, is_road, "道路以外")
    inner_home = c.m("MULTIPLY", c.m("MULTIPLY", is_home, not_road), c.m("SUBTRACT", 1.0, peri), "住宅の内側")
    r_p = cv._rand(ng, cv._v(find(ng, "区画の通し番号")), 21, (1750, -2860), f8, "駐車場の乱数")
    c = cv._Ctx(ng, 1920, -2860, f8)
    is_park = c.m("MULTIPLY", inner_home, c.m("LESS_THAN", r_p, 0.3), "駐車場にする区画")
    c = cv._Ctx(ng, 2430, -2860, f8)
    is_garden = c.m("MULTIPLY", inner_home, c.m("SUBTRACT", 1.0, c.m("LESS_THAN", r_p, 0.3)), "庭にする区画")
    c = cv._Ctx(ng, 1920, -3020, f8)
    is_yard = c.m("MULTIPLY", is_ind, not_road, "工場のヤード")

    join = find(ng, "建物+屋根+道路+地面")
    for keep, m, loc in (
            (is_garden, mat("P1002_Garden", (0.22, 0.34, 0.16, 1.0)), (2950, -2700)),
            (is_park, mat("P1002_Parking", (0.30, 0.30, 0.31, 1.0)), (2950, -3200)),
            (is_yard, mat("P1002_Yard", (0.38, 0.37, 0.35, 1.0)), (2950, -3700))):
        g = ground_patch(ng, pts_geo, keep, m, f8, loc)
        ng.links.new(cv._sock(g[0], g[1], "outputs"), cv._sock(join, "Geometry"))

    return zone


# ---------------------------------------------------------------- 集計・撮影

def read_rows(city):
    dg = bpy.context.evaluated_depsgraph_get()
    dg.update()
    gs = city.evaluated_get(dg).evaluated_geometry()
    pc = gs.instances_pointcloud()
    if not pc:
        return []
    n = len(pc.points)
    cols = {}
    for name in ("gx", "gy", "part", "type", "h", "floors", "col_id", "roof", "sx", "sy", "zone"):
        a = pc.attributes.get(name)
        v = [0.0] * n
        if a is not None:
            a.data.foreach_get("value", v)
        cols[name] = v
    return [{k: cols[k][i] for k in cols} for i in range(n)]


def ground_z_at(x, y):
    dg = bpy.context.evaluated_depsgraph_get()
    hit, loc, *_ = bpy.context.scene.ray_cast(dg, (x, y, 500.0), (0.0, 0.0, -1.0))
    return loc.z if hit else 0.0


def render_views(outdir, views=("wide", "close", "detail", "street"), res=(1600, 900), samples=64):
    sc = bpy.context.scene
    sc.render.resolution_x, sc.render.resolution_y = res
    sc.render.image_settings.file_format = "PNG"
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    shots = os.path.join(outdir, "shots")
    os.makedirs(shots, exist_ok=True)
    size = CELL * (GRID - 1)
    cam = sc.camera
    names = []
    for mode in views:
        gz = None
        if mode == "street":
            cv.set_camera(cam, size, "street", 0.0)
            gz = ground_z_at(cam.location.x, cam.location.y)
        cv.set_camera(cam, size, mode, gz)
        cv.set_lighting(mode in ("detail", "street"))
        sc.render.filepath = os.path.join(shots, f"v7_zones_n{GRID}_{mode}.png")
        bpy.ops.render.render(write_still=True)
        names.append(os.path.basename(sc.render.filepath))
        log("  shot", names[-1])
    cv.set_camera(cam, size, "wide")
    cv.set_lighting(False)
    return names


def main():
    here = os.path.dirname(os.path.abspath(__file__))
    outdir = OUT if os.path.isabs(OUT) else os.path.join(here, OUT)
    os.makedirs(outdir, exist_ok=True)

    city, ng = cv.build_stage("v5_merge", grid_n=GRID, cell=CELL)
    n0 = len(ng.nodes)
    build(ng)
    log("ノード数", n0, "→", len(ng.nodes))

    allrows = read_rows(city)
    rows = [r for r in allrows if r["part"] < 0.5]
    patches = [r for r in allrows if r["part"] > 2.5]
    rec = {"grid": GRID, "cell": CELL, "blender": bpy.app.version_string,
           "buildings": len(rows), "ground_patches": len(patches), "zones": {}}
    for z, name in ZONE_NAMES.items():
        g = [r for r in rows if int(round(r["zone"])) == z]
        if not g:
            continue
        blocks = Counter((int(r["gx"]) // ROAD_EVERY, int(r["gy"]) // ROAD_EVERY) for r in g)
        rec["zones"][name] = {
            "棟数": len(g),
            "割合": round(len(g) / len(rows), 3),
            "1街区あたりの棟数": round(sum(blocks.values()) / len(blocks), 1),
            "階数": {"最小": int(min(r["floors"] for r in g)), "最大": int(max(r["floors"] for r in g)),
                     "平均": round(sum(r["floors"] for r in g) / len(g), 2)},
            "幅": {"最小": round(min(r["sx"] for r in g), 2), "最大": round(max(r["sx"] for r in g), 2)},
            "奥行": {"最小": round(min(r["sy"] for r in g), 2), "最大": round(max(r["sy"] for r in g), 2)},
            "種類": dict(Counter(int(r["type"]) for r in g)),
            "屋根": dict(Counter(int(r["roof"]) for r in g)),
        }
        log(f"{name}: {len(g)}棟 1街区{rec['zones'][name]['1街区あたりの棟数']}棟 "
            f"平均{rec['zones'][name]['階数']['平均']}階 幅{rec['zones'][name]['幅']}")
    log("敷地の板", len(patches), "枚")

    if RENDER:
        rec["shots"] = render_views(outdir)
    if SAVE:
        bpy.ops.wm.save_as_mainfile(filepath=os.path.join(outdir, f"p1002_v7_zones_n{GRID}.blend"))

    with open(os.path.join(outdir, f"zones2_n{GRID}.json"), "w", encoding="utf-8") as fp:
        json.dump(rec, fp, ensure_ascii=False, indent=2)
    with open(os.path.join(outdir, f"rows_v7_zones_n{GRID}.json"), "w", encoding="utf-8") as fp:
        json.dump(rows, fp)
    log("DONE", os.path.join(outdir, f"zones2_n{GRID}.json"))


if __name__ == "__main__":   # run_stages / measure から部品として読み込むときは走らせない
    main()
zones_v8.py … 用途地域:住宅を敷地ごと(庭・駐車場・柵)に・520行
# -*- coding: utf-8 -*-
"""p1002 追加その3: 住宅街を「敷地ごと」に作り込む(v8)。

★city_var.py の作りは変えずに、組み上がったツリーへ足す。
  v5_merge のノードツリーに枠を足し、値を決めている数式ノードの出口だけ差し替える。

v7(zones_v7.py)からの変更は住宅街だけ:
  ・敷地 = **間口1区画 × 奥行き2区画**(街区の外側2列を使う)。1街区16区画
  ・敷地を**1つの部品(メッシュ)**にして、**柵(全周・車の出入口だけ開ける)/駐車場/庭**を作り込む
  ・家は敷地の**中央やや奥**に置く(手前は駐車場、周りは庭)
  ・敷地の部品は家と同じ向きで置くので、**手前=必ず道路側**になる(駐車場も出入口も道路に面する)
  ・街区の中央2×2は共有の裏庭(緑の板)

  blender.exe -b --factory-startup --python zones_v8.py -- --grid 161 --out out/extra_zones3 --render
"""
import bpy
import json
import math
import os
import sys
from collections import Counter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cv  # noqa: E402

# ※2026-10-07 単体実行に加えて、city_var.build_stage("v8_…") から build(ng) を呼べるようにした。
#   起伏をなだらかにする処理(smooth_terrain)は city_var.py の既定に移したので、ここでは呼ばない。

A = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []


def arg(n, d):
    return A[A.index("--" + n) + 1] if "--" + n in A else d


GRID = int(arg("grid", 161))
CELL = float(arg("cell", 2.0))
OUT = arg("out", "out/extra_zones3")
RENDER = "--render" in A
SAVE = "--save" in A
ROAD_EVERY, ROAD_W = 8, 2
HOME_SCALE = 0.68            # 家の大きさ(間口1区画に対して)
SETBACK = 1.2                # 家を手前からどれだけ奥へ置くか(敷地は -0.96〜2.96。中央1.0よりやや奥)
SHED_SX, SHED_SY = 4.6, 3.4  # 生産棟
ZONE_NAMES = {0: "住宅", 1: "商店", 2: "工業"}


def log(*a):
    print("[zones3]", *a, flush=True)


# ---------------------------------------------------------------- 差し替えの道具

def find(ng, label, required=True):
    n = next((x for x in ng.nodes if x.label == label), None)
    if n is None and required:
        raise RuntimeError(f"ラベル『{label}』のノードが見つからない")
    return n


def consumers(ng, node, sock="Value"):
    """★差し替え先は新しいノードを作る前に控える(あとで取ると自分自身が混ざって循環参照になる)。
       `link.to_node is node` は常に偽になるので名前で突き合わせる。"""
    return [(lk.to_node.name, lk.to_socket.identifier) for lk in ng.links
            if lk.from_node.name == node.name and lk.from_socket.name == sock]


def rewire(ng, targets, new_out):
    for node_name, sock_id in targets:
        to_node = ng.nodes[node_name]
        to_sock = next(s for s in to_node.inputs if s.identifier == sock_id)
        ng.links.new(cv._sock(new_out[0], new_out[1], "outputs"), to_sock)


def link_to(ng, out, node, sock_name):
    ng.links.new(cv._sock(out[0], out[1], "outputs"), cv._sock(node, sock_name))


def eq(c, a, b, label=None):
    v = c.m("COMPARE", a, b, label)
    v[0].inputs[2].default_value = 0.1
    return v


def mat(name, rgb, rough=0.95):
    m = bpy.data.materials.get(name) or bpy.data.materials.new(name)
    m.use_nodes = True
    bsdf = m.node_tree.nodes.get("Principled BSDF")
    if bsdf:
        bsdf.inputs["Base Color"].default_value = rgb
        if "Roughness" in bsdf.inputs:
            bsdf.inputs["Roughness"].default_value = rough
    m.diffuse_color = rgb
    return m


# ---------------------------------------------------------------- 敷地の部品をつくる

def box(verts, faces, mats, x0, x1, y0, y1, z0, z1, mi):
    """直方体を1つ足す(柵の棒・駐車場の舗装などに使う)"""
    i = len(verts)
    verts += [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),
              (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]
    quads = [(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0)]
    for q in quads:
        faces.append(tuple(i + k for k in q))
        mats.append(mi)


def build_lot_object():
    """敷地1つぶんのメッシュ(庭・駐車場・柵)。原点は間口側の区画の中心、手前(-Y)が道路。

    形(単位はブレンダーの単位。区画=2.0):
      敷地   X -0.96〜0.96 / Y -0.96〜2.96(間口1区画×奥行き2区画)
      駐車場 手前の左寄り X -0.96〜-0.05 / Y -0.96〜0.20
      柵     全周(高さ0.14・厚み0.06)。**手前は駐車場のぶんだけ開けて車の出入口にする**
    """
    name = "P1002_Lot"
    if name in bpy.data.objects:
        bpy.data.objects.remove(bpy.data.objects[name], do_unlink=True)
    me = bpy.data.meshes.new(name)
    verts, faces, midx = [], [], []

    x0, x1 = -0.96, 0.96
    y0, y1 = -0.96, 2.96
    gate_x = -0.05                      # 車の出入口の右端(ここから右は柵)

    # 0=庭(地面)
    i = len(verts)
    verts += [(x0, y0, 0.02), (x1, y0, 0.02), (x1, y1, 0.02), (x0, y1, 0.02)]
    faces.append((i, i + 1, i + 2, i + 3))
    midx.append(0)
    # 1=駐車場(庭より少し高くして重なりを避ける)
    i = len(verts)
    verts += [(x0, y0, 0.035), (gate_x, y0, 0.035), (gate_x, 0.20, 0.035), (x0, 0.20, 0.035)]
    faces.append((i, i + 1, i + 2, i + 3))
    midx.append(1)
    # 2=柵(全周。手前は出入口のぶんだけ開ける)
    t, h = 0.06, 0.14
    box(verts, faces, midx, x0, x1, y1 - t, y1, 0.02, 0.02 + h, 2)          # 奥
    box(verts, faces, midx, x0, x0 + t, y0, y1, 0.02, 0.02 + h, 2)          # 左
    box(verts, faces, midx, x1 - t, x1, y0, y1, 0.02, 0.02 + h, 2)          # 右
    box(verts, faces, midx, gate_x, x1, y0, y0 + t, 0.02, 0.02 + h, 2)      # 手前(出入口の右側だけ)

    me.from_pydata(verts, [], faces)
    me.update()
    for m in (mat("P1002_Garden", (0.22, 0.34, 0.16, 1.0)),
              mat("P1002_Parking", (0.30, 0.30, 0.31, 1.0)),
              mat("P1002_Fence", (0.62, 0.60, 0.56, 1.0), rough=0.8)):
        me.materials.append(m)
    for p, mi in zip(me.polygons, midx):
        p.material_index = mi

    ob = bpy.data.objects.new(name, me)
    coll = bpy.data.collections.get("P1002_LotColl")
    if coll is None:
        coll = bpy.data.collections.new("P1002_LotColl")
    for o in list(coll.objects):
        coll.objects.unlink(o)
    coll.objects.link(ob)
    log(f"敷地の部品: 面 {len(faces)}(庭1 / 駐車場1 / 柵{len(faces) - 2})")
    return coll


def ground_patch(ng, pts_geo, keep, material, frame, loc, z=0.03):
    x, y = loc
    drop = cv._new_node(ng, "GeometryNodeDeleteGeometry", "敷く点だけ残す", (x, y), frame)
    drop.domain = "POINT"
    cv._link(ng, pts_geo[0], pts_geo[1], drop, "Geometry")
    inv = cv._math(ng, "SUBTRACT", 1.0, keep, "敷かない点", (x - 170, y), frame)
    cv._link(ng, inv, "Value", drop, "Selection")
    tile = cv._new_node(ng, "GeometryNodeMeshGrid", "敷地の板", (x, y - 200), frame)
    cv._sock(tile, "Size X").default_value = CELL * 0.96
    cv._sock(tile, "Size Y").default_value = CELL * 0.96
    cv._sock(tile, "Vertices X").default_value = 2
    cv._sock(tile, "Vertices Y").default_value = 2
    tr = cv._new_node(ng, "GeometryNodeTransform", "わずかに浮かす", (x + 170, y - 200), frame)
    cv._link(ng, tile, "Mesh", tr, "Geometry")
    cv._sock(tr, "Translation").default_value = (0.0, 0.0, z)
    sm = cv._new_node(ng, "GeometryNodeSetMaterial", material.name, (x + 340, y - 200), frame)
    cv._link(ng, tr, "Geometry", sm, "Geometry")
    cv._sock(sm, "Material").default_value = material
    iop = cv._new_node(ng, "GeometryNodeInstanceOnPoints", "敷地を敷く", (x + 510, y), frame)
    cv._link(ng, drop, "Geometry", iop, "Points")
    cv._link(ng, sm, "Geometry", iop, "Instance")
    return cv._store(ng, (iop, "Instances"), "part", 3.0, (x + 680, y), frame, domain="INSTANCE")


# ---------------------------------------------------------------- 本体

def build(ng):
    lot_coll = build_lot_object()
    # ★ノードのラベルは64バイト(日本語で約21文字)まで。長いと黙って途中で切れ、名前で探せなくなる
    f = cv._frame(ng, "⑦ 用途地域(住宅=敷地 / 工業=2棟)", (-2300, -5200))

    gx = cv._named(ng, "gx", (-2250, -5300), f)
    gy = cv._named(ng, "gy", (-2250, -5460), f)
    r_h = cv._v(find(ng, "高さの乱数"))
    r_t = cv._v(find(ng, "種類の乱数"))
    r_c = cv._v(find(ng, "色の乱数"))
    r_r = cv._v(find(ng, "屋根の乱数"))
    bx = cv._v(find(ng, "ブロック内の列"))
    by = cv._v(find(ng, "ブロック内の行"))
    near = cv._v(find(ng, "近さ z"))

    # ---- 用途(中心=商店街 / 外側=住宅街 / 外縁=工業地帯)
    c = cv._Ctx(ng, -2060, -5300, f)
    dx = c.m("FLOOR", c.m("DIVIDE", gx, 40.0), None, "地区の列")
    c = cv._Ctx(ng, -2060, -5460, f)
    dy = c.m("FLOOR", c.m("DIVIDE", gy, 40.0), None, "地区の行")
    c = cv._Ctx(ng, -1720, -5380, f)
    did = c.m("ADD", c.m("MULTIPLY", dy, 100.0), dx, "地区の番号")
    r_z = cv._rand(ng, did, 11, (-1380, -5380), f, "用途の乱数")
    half_n = (GRID - 1) * 0.5
    c = cv._Ctx(ng, -1210, -5300, f)
    cx = c.m("ABSOLUTE", c.m("SUBTRACT", gx, half_n))
    c2 = cv._Ctx(ng, -1210, -5460, f)
    cy = c2.m("ABSOLUTE", c2.m("SUBTRACT", gy, half_n))
    c3 = cv._Ctx(ng, -870, -5380, f)
    u = c3.m("DIVIDE", c3.m("MAXIMUM", cx, cy), half_n, "中心からの四角い距離")
    u2 = c3.m("ADD", u, c3.m("MULTIPLY", c3.m("SUBTRACT", r_z, 0.5), 0.12), "ゆらぎ込み")
    c4 = cv._Ctx(ng, -360, -5300, f)
    is_shop = c4.m("LESS_THAN", u2, 0.30, "商店か")
    c5 = cv._Ctx(ng, -360, -5460, f)
    is_ind = c5.m("MULTIPLY", c5.m("GREATER_THAN", u2, 0.82), c5.m("SUBTRACT", 1.0, is_shop), "工業か")
    c6 = cv._Ctx(ng, -360, -5620, f)
    is_home = c6.m("SUBTRACT", c6.m("SUBTRACT", 1.0, is_shop), is_ind, "住宅か")
    zone = c6.m("ADD", is_shop, c6.m("MULTIPLY", is_ind, 2.0), "用途 0住宅/1商店/2工業")

    # ---- 敷地の割り付け(間口1区画×奥行き2区画。間口の区画=敷地の基点)
    #   南 by=2(bx2〜7)/ 北 by=7(bx2〜7)/ 西 bx=2(by4〜5)/ 東 bx=7(by4〜5)=1街区16敷地
    c = cv._Ctx(ng, -1210, -5800, f)
    mid_y = c.m("MULTIPLY", c.m("GREATER_THAN", by, 3.5), c.m("LESS_THAN", by, 5.5), "行が中ほど(4〜5)")
    c = cv._Ctx(ng, -700, -5800, f)
    a_s = eq(c, by, 2.0, "南向きの敷地")
    c = cv._Ctx(ng, -700, -5960, f)
    a_n = eq(c, by, float(ROAD_EVERY - 1), "北向きの敷地")
    c = cv._Ctx(ng, -700, -6120, f)
    a_w = c.m("MULTIPLY", eq(c, bx, 2.0), mid_y, "西向きの敷地")
    c = cv._Ctx(ng, -700, -6280, f)
    a_e = c.m("MULTIPLY", eq(c, bx, float(ROAD_EVERY - 1)), mid_y, "東向きの敷地")
    c = cv._Ctx(ng, -190, -6040, f)
    anchor = c.m("MAXIMUM", c.m("MAXIMUM", a_s, a_n), c.m("MAXIMUM", a_w, a_e), "敷地の基点")
    # 街区の中央2×2(共有の裏庭)
    c = cv._Ctx(ng, -1210, -6440, f)
    core = c.m("MULTIPLY", c.m("MULTIPLY", c.m("GREATER_THAN", bx, 3.5), c.m("LESS_THAN", bx, 5.5)), mid_y, "中央2×2")

    # ---- 工業(v7と同じ)
    c = cv._Ctx(ng, -1210, -6600, f)
    shed = c.m("MULTIPLY", eq(c, bx, 3.0), eq(c, by, 3.0), "生産棟を置く区画")
    c = cv._Ctx(ng, -1210, -6760, f)
    office = c.m("MULTIPLY", eq(c, bx, 6.0), eq(c, by, 2.0), "オフィス棟を置く区画")

    # ---- 合体(v5)は商店街だけ
    p_m = find(ng, "合体する確率", required=False)
    if p_m is not None:
        used = consumers(ng, p_m)
        c = cv._Ctx(ng, -360, -6900, f)
        rewire(ng, used, c.m("MULTIPLY", cv._v(p_m), is_shop, "合体は商店街だけ"))

    # ---- 建てない区画(住宅=敷地の基点以外 / 工業=2棟以外)
    kill = find(ng, "消す点(合体込み)", required=False) or find(ng, "消す点")
    kill_used = consumers(ng, kill)
    c = cv._Ctx(ng, -360, -7060, f)
    drop_home = c.m("MULTIPLY", is_home, c.m("SUBTRACT", 1.0, anchor), "住宅: 基点以外は建てない")
    c = cv._Ctx(ng, -360, -7220, f)
    drop_ind = c.m("MULTIPLY", is_ind, c.m("SUBTRACT", 1.0, c.m("MAXIMUM", shed, office)), "工業: 2棟以外")
    c = cv._Ctx(ng, 320, -7140, f)
    kill_new = c.m("MAXIMUM", cv._v(kill), c.m("MAXIMUM", drop_home, drop_ind), "消す点(用途ごと)")
    rewire(ng, kill_used, kill_new)

    # ---- 階数 / 種類 / 屋根 / 色(v7と同じ)
    fl_node = find(ng, "階数(D型は3階まで)")
    fl_used = consumers(ng, fl_node)
    c = cv._Ctx(ng, -1210, -7400, f)
    fl_home = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 3.0)), "住宅 1〜3階")
    c = cv._Ctx(ng, -1210, -7560, f)
    fl_shop = c.m("ADD", 3.0, c.m("FLOOR", c.m("MULTIPLY", r_h, c.m("ADD", 3.0, c.m("MULTIPLY", near, 12.0)))),
                  "商店 3階〜")
    c = cv._Ctx(ng, -1210, -7720, f)
    fl_off = c.m("ADD", 3.0, c.m("FLOOR", c.m("MULTIPLY", r_h, 2.0)), "オフィス棟 3〜4階")
    c = cv._Ctx(ng, -700, -7640, f)
    fl_ind = c.m("ADD", 2.0, c.m("MULTIPLY", office, c.m("SUBTRACT", fl_off, 2.0)), "工業")
    c = cv._Ctx(ng, -190, -7560, f)
    fl_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, fl_home), c.m("MULTIPLY", is_shop, fl_shop)),
                 c.m("MULTIPLY", is_ind, fl_ind), "階数(用途ごと)")
    rewire(ng, fl_used, fl_new)

    typ_node = find(ng, "種類 0〜3")
    typ_used = consumers(ng, typ_node)
    c = cv._Ctx(ng, -1210, -7900, f)
    t_home = c.m("MULTIPLY", c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), 3.0, "住宅 0箱/3低層ワイド")
    c = cv._Ctx(ng, -1210, -8060, f)
    t_shop = c.m("ADD", 1.0, c.m("FLOOR", c.m("MULTIPLY", r_t, 2.0)), "商店 1/2")
    c = cv._Ctx(ng, -700, -7980, f)
    t_ind = c.m("MULTIPLY", c.m("SUBTRACT", 1.0, office), 3.0, "工業")
    c = cv._Ctx(ng, -190, -7900, f)
    t_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, t_home), c.m("MULTIPLY", is_shop, t_shop)),
                c.m("MULTIPLY", is_ind, t_ind), "種類(用途ごと)")
    rewire(ng, typ_used, t_new)

    roof_node = find(ng, "屋根 0平/1切妻/2段違い")
    roof_used = consumers(ng, roof_node)
    is_C = cv._v(find(ng, "屋上設備型"))
    c = cv._Ctx(ng, -1210, -8220, f)
    r_home = c.m("LESS_THAN", r_r, 0.75, "住宅 切妻75%")
    c = cv._Ctx(ng, -700, -8220, f)
    roof_new = c.m("MULTIPLY", c.m("MULTIPLY", is_home, r_home), c.m("SUBTRACT", 1.0, is_C), "屋根(用途ごと)")
    rewire(ng, roof_used, roof_new)

    col_node = find(ng, "色の番号 0〜5")
    col_used = consumers(ng, col_node)
    c = cv._Ctx(ng, -1210, -8380, f)
    two = c.m("FLOOR", c.m("MULTIPLY", r_c, 2.0), None, "0か1")
    c = cv._Ctx(ng, -870, -8380, f)
    col_home = c.m("ADD", 2.0, two, "住宅 2白/3ベージュ")
    c = cv._Ctx(ng, -870, -8540, f)
    col_ind = c.m("ADD", 1.0, c.m("MULTIPLY", office, -1.0), "工業")
    c = cv._Ctx(ng, -360, -8460, f)
    col_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, col_home), c.m("MULTIPLY", is_shop, two)),
                  c.m("MULTIPLY", is_ind, col_ind), "色(用途ごと)")
    rewire(ng, col_used, col_new)

    # ---- 大きさ
    sx_node = find(ng, "横の倍率", required=False)
    scl = find(ng, "幅・奥行・高さ")
    sx_used = consumers(ng, sx_node) if sx_node is not None else []
    base_sx = cv._v(sx_node) if sx_node is not None else 1.0
    c = cv._Ctx(ng, -190, -8700, f)
    sx_ind = c.m("ADD", 1.0, c.m("MULTIPLY", shed, SHED_SX - 1.0), "工業の幅")
    c = cv._Ctx(ng, -190, -8860, f)
    sy_ind = c.m("ADD", 1.0, c.m("MULTIPLY", shed, SHED_SY - 1.0), "工業の奥行")
    c = cv._Ctx(ng, 320, -8700, f)
    sx_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, HOME_SCALE), c.m("MULTIPLY", is_shop, base_sx)),
                 c.m("MULTIPLY", is_ind, sx_ind), "幅(用途ごと)")
    c = cv._Ctx(ng, 320, -8860, f)
    sy_new = c.m("ADD", c.m("ADD", c.m("MULTIPLY", is_home, HOME_SCALE), is_shop),
                 c.m("MULTIPLY", is_ind, sy_ind), "奥行(用途ごと)")
    if sx_node is not None:
        rewire(ng, sx_used, sx_new)
    else:
        link_to(ng, sx_new, scl, "X")
    link_to(ng, sy_new, scl, "Y")
    rscl = find(ng, "屋根の倍率", required=False)
    if rscl is not None:
        tf = cv._v(find(ng, "屋上の広さ"))
        c = cv._Ctx(ng, 830, -8860, f)
        link_to(ng, c.m("MULTIPLY", tf, sy_new, "屋根の奥行"), rscl, "Y")

    # ---- 家を敷地の中央やや奥へ下げる(向きに合わせて回す)/生産棟は敷地の中心へ
    iop = find(ng, "点に建物を置く")
    rot_vec = find(ng, "回転(Z)")                 # 建物の向き(CombineXYZ)
    src = next(lk for lk in ng.links if lk.to_node.name == iop.name and lk.to_socket.name == "Points")
    src_node, src_sock = ng.nodes[src.from_node.name], src.from_socket.name
    # ★この点は「建物を置く枝」と「屋根を載せる枝」の両方が使っている。
    #   差し替え先は**ここで**控える(新しいノードを足したあとに取ると空になる)
    orig_used = [(lk.to_node.name, lk.to_socket.identifier) for lk in ng.links
                 if lk.from_node.name == src_node.name and lk.from_socket.name == src_sock]
    geo = cv._store(ng, (src_node, src_sock), "zone", zone, (1600, 300))
    geo = cv._store(ng, geo, "sy", sy_new, (1770, 300))

    f9 = cv._frame(ng, "⑨ 家を敷地の奥へ下げる(手前は駐車場)", (1600, -1600))
    local = cv._new_node(ng, "ShaderNodeCombineXYZ", "敷地の中での位置", (1650, -1700), f9)
    c = cv._Ctx(ng, 1650, -1860, f9)
    link_to(ng, c.m("MULTIPLY", is_home, SETBACK), local, "Y")      # 住宅だけ奥へ
    c = cv._Ctx(ng, 1650, -2020, f9)
    link_to(ng, c.m("MULTIPLY", shed, CELL * 0.5), local, "X")      # 生産棟は右へ半区画
    rot_off = cv._new_node(ng, "ShaderNodeVectorRotate", "向きに合わせて回す", (1990, -1700), f9)
    rot_off.rotation_type = "Z_AXIS"
    cv._link(ng, local, "Vector", rot_off, "Vector")
    ang = find(ng, "向き(度)", required=False)
    if ang is not None:
        c = cv._Ctx(ng, 1820, -2180, f9)
        link_to(ng, c.m("RADIANS", cv._v(ang)), rot_off, "Angle")
    sp = cv._new_node(ng, "GeometryNodeSetPosition", "家を敷地の中へ置く", (2200, 300))
    cv._link(ng, geo[0], geo[1], sp, "Geometry")
    cv._link(ng, rot_off, "Vector", sp, "Offset")
    # 建物・屋根の両方を、属性を足して位置をずらしたあとの点につなぎ替える
    for node_name, sock_id in orig_used:
        to_node = ng.nodes[node_name]
        ng.links.new(cv._sock(sp, "Geometry", "outputs"),
                     next(s2 for s2 in to_node.inputs if s2.identifier == sock_id))

    # ---- 敷地の部品を置く(家と同じ向き=手前が必ず道路側)
    del_r = find(ng, "道路の点だけ残す")
    psrc = next(lk for lk in ng.links if lk.to_node.name == del_r.name and lk.to_socket.name == "Geometry")
    pts_geo = (ng.nodes[psrc.from_node.name], psrc.from_socket.name)
    is_road = cv._v(find(ng, "どちらかが道路"))
    f8 = cv._frame(ng, "⑧ 敷地(柵・駐車場・庭)と工場のヤード", (1600, -3000))
    c = cv._Ctx(ng, 1650, -3100, f8)
    not_road = c.m("SUBTRACT", 1.0, is_road, "道路以外")
    lot_keep = c.m("MULTIPLY", c.m("MULTIPLY", is_home, anchor), c.m("SUBTRACT", 1.0, kill_new), "家が建つ敷地")
    c = cv._Ctx(ng, 1650, -3260, f8)
    core_keep = c.m("MULTIPLY", c.m("MULTIPLY", is_home, core), not_road, "街区中央の裏庭")
    c = cv._Ctx(ng, 1650, -3420, f8)
    yard_keep = c.m("MULTIPLY", is_ind, not_road, "工場のヤード")

    drop = cv._new_node(ng, "GeometryNodeDeleteGeometry", "敷地を置く点だけ残す", (2330, -3100), f8)
    drop.domain = "POINT"
    cv._link(ng, pts_geo[0], pts_geo[1], drop, "Geometry")
    inv = cv._math(ng, "SUBTRACT", 1.0, lot_keep, "置かない点", (2160, -3100), f8)
    cv._link(ng, inv, "Value", drop, "Selection")
    ci = cv._new_node(ng, "GeometryNodeCollectionInfo", "敷地の部品", (2330, -3300), f8)
    cv._sock(ci, "Collection").default_value = lot_coll
    cv._sock(ci, "Separate Children").default_value = False
    cv._sock(ci, "Reset Children").default_value = True
    iop_l = cv._new_node(ng, "GeometryNodeInstanceOnPoints", "敷地を置く", (2670, -3100), f8)
    cv._link(ng, drop, "Geometry", iop_l, "Points")
    cv._link(ng, ci, "Instances", iop_l, "Instance")
    cv._link(ng, rot_vec, "Vector", iop_l, "Rotation")
    lot_geo = cv._store(ng, (iop_l, "Instances"), "part", 4.0, (2840, -3100), f8, domain="INSTANCE")

    join = find(ng, "建物+屋根+道路+地面")
    ng.links.new(cv._sock(lot_geo[0], lot_geo[1], "outputs"), cv._sock(join, "Geometry"))
    for keep, m, loc in ((core_keep, mat("P1002_Garden", (0.22, 0.34, 0.16, 1.0)), (1650, -3800)),
                         (yard_keep, mat("P1002_Yard", (0.38, 0.37, 0.35, 1.0)), (1650, -4300))):
        g = ground_patch(ng, pts_geo, keep, m, f8, loc)
        ng.links.new(cv._sock(g[0], g[1], "outputs"), cv._sock(join, "Geometry"))
    return zone


# ---------------------------------------------------------------- 集計・撮影

def read_rows(city):
    dg = bpy.context.evaluated_depsgraph_get()
    dg.update()
    gs = city.evaluated_get(dg).evaluated_geometry()
    pc = gs.instances_pointcloud()
    if not pc:
        return []
    n = len(pc.points)
    cols = {}
    for name in ("gx", "gy", "part", "type", "h", "floors", "col_id", "roof", "sx", "sy", "zone"):
        a = pc.attributes.get(name)
        v = [0.0] * n
        if a is not None:
            a.data.foreach_get("value", v)
        cols[name] = v
    return [{k: cols[k][i] for k in cols} for i in range(n)]


def ground_z_at(x, y):
    dg = bpy.context.evaluated_depsgraph_get()
    hit, loc, *_ = bpy.context.scene.ray_cast(dg, (x, y, 500.0), (0.0, 0.0, -1.0))
    return loc.z if hit else 0.0


def render_views(outdir, views=("wide", "close", "detail", "street"), res=(1600, 900), samples=64):
    sc = bpy.context.scene
    sc.render.resolution_x, sc.render.resolution_y = res
    sc.render.image_settings.file_format = "PNG"
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    shots = os.path.join(outdir, "shots")
    os.makedirs(shots, exist_ok=True)
    size = CELL * (GRID - 1)
    cam = sc.camera
    names = []
    for mode in views:
        gz = None
        if mode == "street":
            cv.set_camera(cam, size, "street", 0.0)
            gz = ground_z_at(cam.location.x, cam.location.y)
        cv.set_camera(cam, size, mode, gz)
        cv.set_lighting(mode in ("detail", "street"))
        sc.render.filepath = os.path.join(shots, f"v8_lots_n{GRID}_{mode}.png")
        bpy.ops.render.render(write_still=True)
        names.append(os.path.basename(sc.render.filepath))
        log("  shot", names[-1])
    cv.set_camera(cam, size, "wide")
    cv.set_lighting(False)
    return names


def main():
    here = os.path.dirname(os.path.abspath(__file__))
    outdir = OUT if os.path.isabs(OUT) else os.path.join(here, OUT)
    os.makedirs(outdir, exist_ok=True)

    city, ng = cv.build_stage("v5_merge", grid_n=GRID, cell=CELL)
    n0 = len(ng.nodes)
    build(ng)
    log("ノード数", n0, "→", len(ng.nodes))

    allrows = read_rows(city)
    rows = [r for r in allrows if r["part"] < 0.5]
    lots = [r for r in allrows if r["part"] > 3.5]
    patches = [r for r in allrows if 2.5 < r["part"] < 3.5]
    rec = {"grid": GRID, "cell": CELL, "blender": bpy.app.version_string,
           "buildings": len(rows), "lots": len(lots), "ground_patches": len(patches), "zones": {}}
    for z, name in ZONE_NAMES.items():
        g = [r for r in rows if int(round(r["zone"])) == z]
        if not g:
            continue
        blocks = Counter((int(r["gx"]) // ROAD_EVERY, int(r["gy"]) // ROAD_EVERY) for r in g)
        rec["zones"][name] = {
            "棟数": len(g), "割合": round(len(g) / len(rows), 3),
            "1街区あたりの棟数": round(sum(blocks.values()) / len(blocks), 1),
            "階数": {"最小": int(min(r["floors"] for r in g)), "最大": int(max(r["floors"] for r in g)),
                     "平均": round(sum(r["floors"] for r in g) / len(g), 2)},
        }
        log(f"{name}: {len(g)}棟 1街区{rec['zones'][name]['1街区あたりの棟数']}棟 "
            f"平均{rec['zones'][name]['階数']['平均']}階")
    log("敷地の部品", len(lots), "/ 板", len(patches))

    if RENDER:
        rec["shots"] = render_views(outdir)
    if SAVE:
        bpy.ops.wm.save_as_mainfile(filepath=os.path.join(outdir, f"p1002_v8_lots_n{GRID}.blend"))
    with open(os.path.join(outdir, f"zones3_n{GRID}.json"), "w", encoding="utf-8") as fp:
        json.dump(rec, fp, ensure_ascii=False, indent=2)
    with open(os.path.join(outdir, f"rows_v8_lots_n{GRID}.json"), "w", encoding="utf-8") as fp:
        json.dump(rows, fp)
    log("DONE", os.path.join(outdir, f"zones3_n{GRID}.json"))


if __name__ == "__main__":   # run_stages / measure から部品として読み込むときは走らせない
    main()
run_stages.py … 段階ごとに街を作り、そっくり率を数え、画像を撮る・286行
# -*- coding: utf-8 -*-
"""
p1002: 各段階の街をつくり、「隣とそっくり」の割合を数え、画像を撮る

使い方(-b=画面なしでよい):
  blender -b --python run_stages.py -- --grid 161 --out out --render
  blender -b --python run_stages.py -- --stages v0_p973,v5_merge --grid 81
  blender -b --python run_stages.py -- --stages v5_merge --grid 161 --terrain old --road-slope
      (--terrain old=最初の起伏に戻す/--road-slope=道路の上の勾配を測る)

生成物:
  out/stages_n<grid>.json   … 段階ごとの棟数・分身の数・そっくり率など
  out/shots/<stage>_n<grid>_<view>.png   (--render のとき)
  out/p1002_<stage>_n<grid>.blend        (--save のとき)
"""

import bpy
import sys
import os
import json
import math
import time
from collections import Counter

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cv  # noqa: E402

DEFAULT_STAGES = ["v0_p973", "v1_orient", "v2_floors", "v3_roofs", "v4_windows", "v5_merge",
                  "v6_zones", "v7_zones", "v8_lots"]
VIEWS = ["wide", "close", "detail", "street", "houses", "industry", "lots"]


def parse_args():
    a = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    out = {"grid": 161, "cell": 2.0, "out": "out", "stages": DEFAULT_STAGES,
           "render": False, "save": False, "dump": False, "views": VIEWS, "res": (1600, 900), "samples": 32,
           "terrain": "new", "road_slope": False}
    i = 0
    while i < len(a):
        k = a[i].lstrip("-")
        if k == "grid":
            out["grid"] = int(a[i + 1]); i += 2
        elif k == "out":
            out["out"] = a[i + 1]; i += 2
        elif k == "stages":
            out["stages"] = a[i + 1].split(","); i += 2
        elif k == "views":
            out["views"] = a[i + 1].split(","); i += 2
        elif k == "res":
            w, h = a[i + 1].split("x"); out["res"] = (int(w), int(h)); i += 2
        elif k == "samples":
            out["samples"] = int(a[i + 1]); i += 2
        elif k == "render":
            out["render"] = True; i += 1
        elif k == "save":
            out["save"] = True; i += 1
        elif k == "dump":
            out["dump"] = True; i += 1
        elif k == "terrain":
            out["terrain"] = a[i + 1]; i += 2
        elif k == "road-slope":
            out["road_slope"] = True; i += 1
        else:
            i += 1
    return out


# ---------------------------------------------------------------- 分身の属性を読む

def read_instances(city):
    """評価後の街から、分身1つずつの属性を取り出す(建物・屋根・道路)"""
    dg = bpy.context.evaluated_depsgraph_get()
    dg.update()
    gs = city.evaluated_get(dg).evaluated_geometry()
    pc = gs.instances_pointcloud()
    mesh = gs.mesh
    res = {"n_instances": len(pc.points) if pc else 0,
           "mesh_faces": len(mesh.polygons) if mesh else 0,
           "rows": []}
    if not pc:
        return res
    n = len(pc.points)
    cols = {}
    for name in ("gx", "gy", "part", "type", "h", "floors", "col_id", "roof", "rot", "sx", "sy", "zone"):
        a = pc.attributes.get(name)
        v = [0.0] * n
        if a is not None:
            a.data.foreach_get("value", v)
        cols[name] = v
    ref = [0] * n
    pc.attributes[".reference_index"].data.foreach_get("value", ref)
    refs = gs.instance_references()
    face_of = [cv.ref_faces(r) for r in refs]
    res["unique_meshes"] = len(set(ref))
    res["drawn_faces"] = sum(face_of[i] if i < len(face_of) else 0 for i in ref) + res["mesh_faces"]
    res["rows"] = [{k: cols[k][i] for k in cols} for i in range(n)]
    return res


# ---------------------------------------------------------------- そっくり率

def look_of(b, use_rot=True):
    """見た目の特徴。向きは、向きで見た目が変わる建物(屋上設備型・切妻屋根)のときだけ数える"""
    rot = 0
    if use_rot:
        deg = round(math.degrees(b["rot"])) % 360
        if b["roof"] == 1:            # 切妻は180度回しても同じ形
            rot = deg % 180
        elif b["type"] == 2:          # 屋上設備型
            rot = deg
    return (int(b["type"]), int(b["roof"]), int(b["col_id"]), rot, b["sx"] > 1.5)


def similarity(rows, h_tol=0.10):
    """となり合う建物(上下左右)の組のうち、『そっくり』な組の割合。
    そっくり=種類・屋根・色・(意味があるときは)向き・合体の有無が同じで、高さの差が h_tol 以内"""
    blds = [r for r in rows if r["part"] < 0.5]
    owner = {}
    for i, b in enumerate(blds):
        gx, gy = int(round(b["gx"])), int(round(b["gy"]))
        owner[(gx, gy)] = i
        if b["sx"] > 1.5:             # 合体した棟は右の区画も持っている
            owner[(gx + 1, gy)] = i
    pairs = set()
    for (gx, gy), i in owner.items():
        for d in ((1, 0), (0, 1)):
            j = owner.get((gx + d[0], gy + d[1]))
            if j is not None and j != i:
                pairs.add((min(i, j), max(i, j)))
    twins = same_shape = 0
    for i, j in pairs:
        a, b = blds[i], blds[j]
        if look_of(a) != look_of(b):
            continue
        same_shape += 1               # 高さを問わなければ同じ
        hi = max(a["h"], b["h"], 1e-6)
        if abs(a["h"] - b["h"]) / hi <= h_tol:
            twins += 1
    # 見た目の種類(高さは1階ぶん刻みに丸める)
    looks = Counter(look_of(b) + (int(round(b["h"] / cv.FLOOR_H)),) for b in blds)
    top = looks.most_common(1)[0][1] if looks else 0
    return {"buildings": len(blds), "pairs": len(pairs), "twins": twins,
            "twin_ratio": round(twins / len(pairs), 4) if pairs else None,
            "same_shape": same_shape,
            "same_shape_ratio": round(same_shape / len(pairs), 4) if pairs else None,
            "distinct_looks": len(looks),
            "top_look_share": round(top / len(blds), 4) if blds else None,
            "types": dict(Counter(int(b["type"]) for b in blds)),
            "roofs": dict(Counter(int(b["roof"]) for b in blds)),
            "colors": dict(Counter(int(b["col_id"]) for b in blds)),
            "merged": sum(1 for b in blds if b["sx"] > 1.5),
            "floors_min": int(min(round(b["floors"]) for b in blds)) if blds else None,
            "floors_max": int(max(round(b["floors"]) for b in blds)) if blds else None}


# ---------------------------------------------------------------- 道路の勾配

def road_slope(grid, cell, road_every=8):
    """道路の中心線(1区画目)の上だけで地面の高さを拾い、隣の区画との段差から勾配を出す。
    ★建物に当たると数字が壊れるので、道路の上だけを測る"""
    half = cell * (grid - 1) * 0.5
    zs = {}
    for gy in range(grid):
        for gx in range(grid):
            if gx % road_every == 0 or gy % road_every == 0:
                zs[(gx, gy)] = ground_z_at(gx * cell - half, gy * cell - half)
    steps = []
    for (gx, gy), z in zs.items():
        for dx, dy in ((1, 0), (0, 1)):
            o = zs.get((gx + dx, gy + dy))
            if o is not None:
                steps.append(abs(o - z))
    return {"slope_mean_pct": round(sum(steps) / len(steps) / cell * 100, 2),
            "slope_max_pct": round(max(steps) / cell * 100, 2),
            "height_range": round(max(zs.values()) - min(zs.values()), 3)}


# ---------------------------------------------------------------- 撮影

def ground_z_at(x, y):
    dg = bpy.context.evaluated_depsgraph_get()
    hit, loc, *_ = bpy.context.scene.ray_cast(dg, (x, y, 500.0), (0.0, 0.0, -1.0))
    return loc.z if hit else 0.0


# 用途地域の寄り:(街区の中心の列番号の、中心からのずれ, 距離, 高さ, 向き(度))
#   街区は8区画ごと。中の区画は 8k+2〜8k+7 なので中心は 8k+4.5
AT_VIEWS = {
    "houses":   (36, 0, 55.0, 28.0, 35.0),   # 中心から4.5街区=住宅街
    "industry": (0, -72, 55.0, 28.0, 20.0),  # 南の外縁=工業地帯。街の外から内側を向いて撮る
    "lots":     (36, 0, 16.0, 8.0, 35.0),    # 住宅の敷地に寄る
}


def set_camera_at(cam, grid, cell, key):
    """街区を指定して斜め上から撮る(Windows で撮った寄りと同じ置き方)"""
    from mathutils import Vector
    dgx, dgy, dist, height, yaw = AT_VIEWS[key]
    half_n = (grid - 1) // 2
    gx = 8 * ((half_n + dgx) // 8) + 4.5
    gy = 8 * ((half_n + dgy) // 8) + 4.5
    x, y = gx * cell - (grid - 1) * cell * 0.5, gy * cell - (grid - 1) * cell * 0.5
    target = Vector((x, y, ground_z_at(x, y) + 2.0))
    yaw = math.radians(yaw)
    cam.location = target + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, height))
    cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
    cam.data.lens = 40


def render_views(outdir, stage, grid, cell, views, res, samples):
    sc = bpy.context.scene
    sc.render.resolution_x, sc.render.resolution_y = res
    sc.render.image_settings.file_format = "PNG"
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    shots = os.path.join(outdir, "shots")
    os.makedirs(shots, exist_ok=True)
    size = cell * (grid - 1)
    cam = sc.camera
    names = []
    for mode in views:
        if mode in AT_VIEWS:
            if not stage.startswith(("v6", "v7", "v8")):
                continue          # 用途地域の寄りは v6 以降だけ
            set_camera_at(cam, grid, cell, mode)
        else:
            gz = None
            if mode == "street":
                cv.set_camera(cam, size, "street", 0.0)
                gz = ground_z_at(cam.location.x, cam.location.y)
            cv.set_camera(cam, size, mode, gz)
        cv.set_lighting(mode in ("detail", "street") or mode in AT_VIEWS)
        sc.render.filepath = os.path.join(shots, f"{stage}_n{grid}_{mode}.png")
        bpy.ops.render.render(write_still=True)
        names.append(os.path.basename(sc.render.filepath))
        print(f"[p1002]   shot {names[-1]}", flush=True)
    cv.set_camera(cam, size, "wide")
    cv.set_lighting(False)
    return names


# ---------------------------------------------------------------- 実行

def main():
    args = parse_args()
    here = os.path.dirname(os.path.abspath(__file__))
    outdir = args["out"] if os.path.isabs(args["out"]) else os.path.join(here, args["out"])
    os.makedirs(outdir, exist_ok=True)
    result = {}
    for st in args["stages"]:
        t0 = time.perf_counter()
        over = cv.TERRAIN_OLD if args["terrain"] == "old" else {}
        city, ng = cv.build_stage(st, grid_n=args["grid"], cell=args["cell"], **over)
        inst = read_instances(city)
        rec = {"nodes": len(ng.nodes),
               "instances": inst["n_instances"],
               "unique_meshes": inst.get("unique_meshes"),
               "drawn_faces": inst.get("drawn_faces", inst["mesh_faces"]),
               "parts": dict(Counter(int(r["part"]) for r in inst["rows"])),
               "build_sec": round(time.perf_counter() - t0, 2)}
        if inst["rows"]:
            rec.update(similarity(inst["rows"]))
        if args["road_slope"]:
            rec.update(road_slope(args["grid"], args["cell"]))
            print(f"[p1002] {st} 道路の勾配 平均 {rec['slope_mean_pct']}% / 最大 {rec['slope_max_pct']}%", flush=True)
        if args["dump"]:   # 建物1棟ずつの値(段階間の比較用)
            with open(os.path.join(outdir, f"rows_{st}_n{args['grid']}.json"), "w", encoding="utf-8") as f:
                json.dump([r for r in inst["rows"] if r["part"] < 0.5], f)
        if args["render"]:
            rec["shots"] = render_views(outdir, st, args["grid"], args["cell"],
                                        args["views"], args["res"], args["samples"])
        if args["save"]:
            bpy.ops.wm.save_as_mainfile(filepath=os.path.join(outdir, f"p1002_{st}_n{args['grid']}.blend"))
        result[st] = rec
        print(f"[p1002] {st:<18} bld={rec.get('buildings')} inst={rec['instances']} "
              f"twins={rec.get('twin_ratio')} looks={rec.get('distinct_looks')}", flush=True)
        with open(os.path.join(outdir, f"stages_n{args['grid']}.json"), "w", encoding="utf-8") as f:
            json.dump({"grid": args["grid"], "cell": args["cell"],
                       "blender": bpy.app.version_string, "stages": result},
                      f, ensure_ascii=False, indent=2)
    print("[p1002] DONE", flush=True)


main()
measure.py … 再計算と描画の重さを測る(画面あり)・301行
# -*- coding: utf-8 -*-
"""
p1002: 「重さ」を2種類に分けて測る(p973の measure.py を流用)

  (a) ノード再計算の時間 … パラメータを動かしたときの待ち時間(CPU側)
  (b) 描画の時間        … 視点を回したときの滑らかさ(GPU側)

この2つは別物で、作り込みの内容によってどちらが伸びるかが変わる。
そこを分けて測るのがこの記事の芯。

使い方(★GUIで起動する。-b を付けない):
  blender.exe --python measure.py -- --grids 81,161,241 --out results --quit

  ※ -b(バックグラウンド)では描画の計測ができないため、必ずGUIで実行する。
  ※ 計測中はBlenderのウィンドウを触らないこと(描画時間に影響する)。

出力:
  results/measure.json / measure.csv
"""

import bpy
import sys
import os
import json
import time
import math
import statistics

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cb  # noqa: E402

DEFAULT_STAGES = ["v0_p973", "v1_orient", "v2_floors", "v3_roofs", "v4_windows", "v5_merge",
                  "v6_zones", "v7_zones", "v8_lots",
                  "e3_parts_realize", "e3_baked4", "e3_baked16", "e3_baked64"]


# ---------------------------------------------------------------- 引数

def parse_args():
    a = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    out = {"grids": [81, 161, 241], "stages": DEFAULT_STAGES,
           "out": "results", "frames": 12, "reps": 7, "cell": 2.0, "quit": False}
    i = 0
    while i < len(a):
        k = a[i].lstrip("-")
        if k == "grids":
            out["grids"] = [int(x) for x in a[i + 1].split(",")]; i += 2
        elif k == "stages":
            out["stages"] = a[i + 1].split(","); i += 2
        elif k == "out":
            out["out"] = a[i + 1]; i += 2
        elif k == "frames":
            out["frames"] = int(a[i + 1]); i += 2
        elif k == "reps":
            out["reps"] = int(a[i + 1]); i += 2
        elif k == "cell":
            out["cell"] = float(a[i + 1]); i += 2
        elif k == "quit":
            out["quit"] = True; i += 1
        else:
            i += 1
    return out


# ---------------------------------------------------------------- (a) 再計算

def measure_eval_ms(city_obj, ng, reps=7):
    """グリッドの大きさをごくわずかに動かして、街を作り直させる。
    その1回にかかる時間を測る(=スライダーを動かしたときの待ち時間)"""
    grid = next((n for n in ng.nodes if n.bl_idname == "GeometryNodeMeshGrid"
                 and n.label == "地面グリッド"), None)
    if grid is None:
        grid = next(n for n in ng.nodes if n.bl_idname == "GeometryNodeMeshGrid")
    sock = grid.inputs["Size X"]
    base = sock.default_value

    times = []
    for i in range(reps + 1):
        sock.default_value = base + 0.0001 * (i + 1)
        city_obj.update_tag()
        t0 = time.perf_counter()
        dg = bpy.context.evaluated_depsgraph_get()
        dg.update()
        ev = city_obj.evaluated_get(dg)
        _ = ev.bound_box[:]              # ここで評価が確定する
        times.append((time.perf_counter() - t0) * 1000.0)
    sock.default_value = base
    city_obj.update_tag()
    bpy.context.evaluated_depsgraph_get().update()
    times = times[1:]                    # 1回目は準備が混ざるので捨てる
    return {"eval_ms_median": round(statistics.median(times), 2),
            "eval_ms_min": round(min(times), 2),
            "eval_ms_max": round(max(times), 2)}


# ---------------------------------------------------------------- (b) 描画

def setup_orbit(cam):
    """カメラを空オブジェクトにぶら下げる。あとでこの空を回して視点を動かす。
    ※ キーフレームは使わない(5.xでActionの構造が変わっており、版に依存させたくない)"""
    piv = bpy.data.objects.get("OrbitPivot")
    if piv is None:
        piv = bpy.data.objects.new("OrbitPivot", None)
        bpy.context.collection.objects.link(piv)
    piv.location = (0, 0, 0)
    piv.rotation_euler = (0, 0, 0)
    if cam.parent != piv:
        # ★matrix_world を代入して位置を保とうとすると、GUIではカメラが原点に潰れた
        #   (ローカル行列が更新前の値のまま残るため)。
        #   親は原点・無回転なので、カメラのローカル座標をそのまま使えばよい。
        cam.parent = piv
        cam.matrix_parent_inverse.identity()
    bpy.context.view_layer.update()
    return piv


def find_view3d():
    for win in bpy.context.window_manager.windows:
        for area in win.screen.areas:
            if area.type == "VIEW_3D":
                region = next((r for r in area.regions if r.type == "WINDOW"), None)
                return win, area, region
    return None, None, None


def wait_shaders(win, area, region, timeout=180.0):
    """★マテリアル表示では、シェーダーのコンパイルが裏で進む。終わる前に測ると
    『仮の色で描いた時間』を測ってしまうので、コンパイルが終わるまで描き直しながら待つ。
    (p976で、最初にシェーダーを要求した処理だけ約15秒余計にかかった)"""
    t0 = time.perf_counter()
    while time.perf_counter() - t0 < timeout:
        with bpy.context.temp_override(window=win, area=area, region=region):
            bpy.ops.wm.redraw_timer(type="DRAW", iterations=3)
        busy = False
        try:
            busy = bpy.app.is_job_running("SHADER_COMPILATION")
        except Exception:
            pass
        if not busy:
            break
    return round(time.perf_counter() - t0, 2)


def measure_draw_ms(piv, frames, shading="SOLID", iters=20):
    """視点を少しずつ回しながら、ビューポートを実際に描き直させて1コマの時間を測る。
    Blender自身のベンチ用オペレータ `wm.redraw_timer`(ウィンドウを描いて画面に出す)を使う。

    ★ここではノードの再計算は起きない(形は変えていない)ので、純粋に描画側の重さになる。
    ★カメラビュー固定・シェーディング固定で、段階間の条件を揃える。"""
    win, area, region = find_view3d()
    if area is None:
        return {}

    space = area.spaces.active
    try:
        space.shading.type = shading
        space.region_3d.view_perspective = "CAMERA"
        space.overlay.show_overlays = False
    except Exception:
        pass
    wait_s = wait_shaders(win, area, region)

    times, err = [], None
    for i in range(frames + 1):          # 1回目は準備が混ざるので後で捨てる
        piv.rotation_euler[2] = math.radians(360.0 * i / max(frames, 1))
        bpy.context.view_layer.update()
        t0 = time.perf_counter()
        try:
            with bpy.context.temp_override(window=win, area=area, region=region):
                # ★DRAW_WIN_SWAP は画面への表示(スワップ)を含むため vsync で頭打ちになる
                #   (120Hzのディスプレイでは 8.3ms より速い値が出ない)。
                #   ここでは描画そのものだけを測りたいので "DRAW" を使う。
                bpy.ops.wm.redraw_timer(type="DRAW", iterations=iters)
        except Exception as e:
            err = str(e)
            break
        times.append((time.perf_counter() - t0) * 1000.0 / iters)

    piv.rotation_euler[2] = 0.0
    key = "draw_solid_ms" if shading == "SOLID" else "draw_material_ms"
    if not times:
        return {key: None, "draw_note": f"redraw_timer失敗: {err}"}
    times = times[1:] or times
    return {key: round(statistics.median(times), 2),
            key + "_worst": round(max(times), 2),
            key + "_wait_s": wait_s,
            "draw_note": err or ""}


def save_shot(outdir, stage, grid, samples=24):
    """計測した状態の街を1枚レンダリングして残す。
    ★数字だけだと『本当に街が映っていたのか』を後から確認できないため、証拠として撮る。
    ★ビューポートのOpenGLレンダー(render.opengl)は、ウィンドウが前面に無い状態だと
      中身が空の画像になることがあったので、通常のレンダー(F12相当)を使う。"""
    sc = bpy.context.scene
    d = os.path.join(outdir, "shots")
    os.makedirs(d, exist_ok=True)
    sc.render.filepath = os.path.join(d, f"{stage}_n{grid}.png")
    sc.render.image_settings.file_format = "PNG"
    sc.render.resolution_x, sc.render.resolution_y = 1280, 720
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    try:
        bpy.ops.render.render(animation=False, write_still=True)
        return os.path.basename(sc.render.filepath)
    except Exception as e:
        return f"失敗: {e}"


# ---------------------------------------------------------------- 規模

def measure_size(city_obj):
    """分身の数・描く面の総数・使っている型の数"""
    dg = bpy.context.evaluated_depsgraph_get()
    dg.update()
    gs = city_obj.evaluated_get(dg).evaluated_geometry()
    pc = gs.instances_pointcloud()
    mesh = gs.mesh
    n = len(pc.points) if pc else 0
    base = len(mesh.polygons) if mesh else 0
    drawn, uniq = base, 0
    if pc:
        ref = [0] * n
        pc.attributes[".reference_index"].data.foreach_get("value", ref)
        faces = [cb.ref_faces(r) for r in gs.instance_references()]
        drawn += sum(faces[i] for i in ref if i < len(faces))
        uniq = len(set(ref))
    return {"instances": n, "unique_refs": uniq, "drawn_polys": drawn,
            "base_polys": base}


# ---------------------------------------------------------------- 実行

def main():
    args = parse_args()
    here = os.path.dirname(os.path.abspath(__file__))
    outdir = args["out"] if os.path.isabs(args["out"]) else os.path.join(here, args["out"])
    os.makedirs(outdir, exist_ok=True)

    # ★捨てレンダリングを1回だけ先に通す。
    #   EEVEEでレンダリングする前のビューポートは、マテリアル表示が軽い経路で描かれる
    #   (Windows実測:v5_merge/161 で 3.08ms → レンダリング後 32.56ms)。
    #   1条件ごとに証拠画像をレンダリングしているので、2条件目以降は本来の重さで測れるが、
    #   最初の1条件だけ軽く出てしまう。コンパイル待ち(is_job_running)では検知できない。
    cb.build_stage(args["stages"][0], grid_n=args["grids"][0], cell=args["cell"])
    save_shot(outdir, "warmup", args["grids"][0], samples=1)

    rows = []
    for grid in args["grids"]:
        for stage in args["stages"]:
            print(f"[p1002] === {stage} / grid={grid} ===", flush=True)
            t0 = time.perf_counter()
            city, ng = cb.build_stage(stage, grid_n=grid, cell=args["cell"])
            cam = bpy.context.scene.camera
            piv = setup_orbit(cam)

            rec = {"stage": stage, "grid": grid, "cell": args["cell"],
                   "nodes": len(ng.nodes),
                   "build_sec": round(time.perf_counter() - t0, 2)}
            rec.update(measure_size(city))
            rec.update(measure_eval_ms(city, ng, reps=args["reps"]))
            rec.update(measure_draw_ms(piv, args["frames"], "SOLID"))
            rec.update(measure_draw_ms(piv, args["frames"], "MATERIAL"))
            dgx = bpy.context.evaluated_depsgraph_get(); dgx.update()
            evx = city.evaluated_get(dgx)
            print("[p1002] 確認: カメラ", tuple(round(v, 1) for v in bpy.context.scene.camera.matrix_world.translation),
                  "clip", round(bpy.context.scene.camera.data.clip_end, 1),
                  "bbox", tuple(round(c, 1) for c in evx.bound_box[6]),
                  "vis", city.visible_get(),
                  "objs", len(bpy.context.view_layer.objects),
                  flush=True)
            rec["shot"] = save_shot(outdir, stage, grid)
            rows.append(rec)
            print(f"[p1002] {stage} n={grid} inst={rec.get('instances')} "
                  f"eval={rec.get('eval_ms_median')}ms "
                  f"draw(solid)={rec.get('draw_solid_ms')}ms "
                  f"draw(material)={rec.get('draw_material_ms')}ms", flush=True)

            with open(os.path.join(outdir, "measure.json"), "w", encoding="utf-8") as f:
                json.dump(rows, f, ensure_ascii=False, indent=2)

    cols = ["stage", "grid", "nodes", "instances", "unique_refs", "drawn_polys", "base_polys",
            "eval_ms_median", "eval_ms_min", "eval_ms_max",
            "draw_solid_ms", "draw_solid_ms_worst",
            "draw_material_ms", "draw_material_ms_worst", "draw_material_ms_wait_s",
            "shot", "draw_note"]
    with open(os.path.join(outdir, "measure.csv"), "w", encoding="utf-8") as f:
        f.write(",".join(cols) + "\n")
        for r in rows:
            f.write(",".join(str(r.get(c, "")).replace(",", " ") for c in cols) + "\n")
    print("[p1002] wrote", os.path.join(outdir, "measure.csv"))
    print("[p1002] DONE")
    if args["quit"]:
        # 起動直後のスクリプトからは quit がその場で効かないので、
        # タイマー経由で少し遅らせて閉じる(GUI実行時のみ意味がある)
        bpy.app.timers.register(lambda: (bpy.ops.wm.quit_blender(), None)[1], first_interval=0.5)


main()
compare_rows.py … 2つの街の建物を区画ごとに突き合わせる・34行
# -*- coding: utf-8 -*-
"""p1002: 2つの街の「建物1棟ずつの値」(run_stages.py --dump の rows_*.json)を区画ごとに突き合わせる。

  使い方(Blender に入っている Python で動く。bpy は使わない):
    blender -b --factory-startup --python compare_rows.py -- A.json B.json

  何に使うか:
    ・合体の前後(A=v4, B=v5)で、合体と関係ない建物まで変わったかを数える
    ・Mac と Windows で同じ段階を作り、同じ街になっているかを確かめる
"""
import json
import sys

a, b = sys.argv[sys.argv.index("--") + 1:][:2]
A = {(round(r["gx"]), round(r["gy"])): r for r in json.load(open(a, encoding="utf-8"))}
B = {(round(r["gx"]), round(r["gy"])): r for r in json.load(open(b, encoding="utf-8"))}

only_a, only_b = sorted(set(A) - set(B)), sorted(set(B) - set(A))
both = [k for k in A if k in B and B[k]["sx"] < 1.5 and A[k]["sx"] < 1.5]   # 合体していない建物だけ比べる
keys = ("type", "h", "roof", "col_id", "rot")
changed = {k: 0 for k in keys}
any_changed = 0
for c in both:
    diff = [k for k in keys if abs(A[c][k] - B[c][k]) > 1e-4]
    for k in diff:
        changed[k] += 1
    any_changed += bool(diff)

print(f"Aだけにある建物 {len(only_a)}  Bだけにある建物 {len(only_b)}")
print(f"両方にある単独の建物 {len(both)}  そのうち値が変わった {any_changed}"
      f"({any_changed / max(len(both), 1):.1%})")
print("  変わった項目ごと:", changed)
if 0 < len(only_a) + len(only_b) <= 20:
    print("  片方にしかない区画:", only_a, only_b)
probe_render.py … レンダリングの前と後で描画の重さを比べる(画面あり)・140行
# -*- coding: utf-8 -*-
"""p1002: EEVEE でレンダリングしたあと、ビューポートのマテリアル表示が重くなるかを確かめる。

measure.py は 1条件ごとに「描画を測る → 証拠画像をレンダリングする」の順で回る。
つまり2条件目以降は、必ず「レンダリング済みのプロセス」で測っていることになる。
ここでは同じ条件のマテリアル表示を
  レンダリング前 → レンダリング1回 → 後 → もう1回 → 後
と測って、順番そのものが数字を変えていないかを見る。

  blender --factory-startup --python probe_render.py -- --stage v5_merge --grid 161 --out out
  blender --factory-startup --python probe_render.py -- --stage v5_merge --grid 161 --out out --hold 20
    (--hold 秒:最後にマテリアル表示のまま、その秒数だけ視点を回しながら描き続ける。
      そのあいだに別のウィンドウで nvidia-smi などを動かすと、GPU が詰まっているかが見える)
"""
import bpy
import json
import math
import os
import statistics
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import city_var as cb  # noqa: E402

A = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []


def arg(n, d):
    return A[A.index("--" + n) + 1] if "--" + n in A else d


STAGE, GRID, OUT = arg("stage", "v5_merge"), int(arg("grid", 161)), arg("out", "out")
HOLD = float(arg("hold", 0))


def log(*a):
    print("[order]", *a, flush=True)


def win():
    return bpy.context.window_manager.windows[0]


def view3d():
    for a in win().screen.areas:
        if a.type == "VIEW_3D":
            return a, next((r for r in a.regions if r.type == "WINDOW"), None)
    return None, None


def measure(area, region, piv, shading, frames=12, iters=10):
    sp = area.spaces.active
    sp.shading.type = shading
    with bpy.context.temp_override(window=win(), area=area, region=region):
        bpy.ops.wm.redraw_timer(type="DRAW", iterations=5)
    t = []
    for i in range(frames + 1):
        piv.rotation_euler[2] = math.radians(360.0 * i / frames)
        bpy.context.view_layer.update()
        t0 = time.perf_counter()
        with bpy.context.temp_override(window=win(), area=area, region=region):
            bpy.ops.wm.redraw_timer(type="DRAW", iterations=iters)
        t.append((time.perf_counter() - t0) * 1000.0 / iters)
    return round(statistics.median(t[1:]), 2)


def render_once(samples=24):
    sc = bpy.context.scene
    sc.render.resolution_x, sc.render.resolution_y = 1280, 720
    sc.render.image_settings.file_format = "PNG"
    sc.render.filepath = os.path.join(OUT, "order_tmp.png")
    try:
        sc.eevee.taa_render_samples = samples
    except Exception:
        pass
    t0 = time.perf_counter()
    bpy.ops.render.render(animation=False, write_still=True)
    return round(time.perf_counter() - t0, 2)


def run():
    city, ng = cb.build_stage(STAGE, grid_n=GRID, cell=2.0)
    cam = bpy.context.scene.camera
    piv = bpy.data.objects.get("OrbitPivot") or bpy.data.objects.new("OrbitPivot", None)
    if piv.name not in bpy.context.collection.objects:
        bpy.context.collection.objects.link(piv)
    piv.location = (0, 0, 0)
    piv.rotation_euler = (0, 0, 0)
    if cam.parent != piv:
        cam.parent = piv
        cam.matrix_parent_inverse.identity()
    bpy.context.view_layer.update()

    area, region = view3d()
    area.spaces.active.region_3d.view_perspective = "CAMERA"
    area.spaces.active.overlay.show_overlays = False
    os.makedirs(OUT, exist_ok=True)

    rec = {"stage": STAGE, "grid": GRID, "steps": []}

    def note(tag):
        s = measure(area, region, piv, "SOLID")
        m = measure(area, region, piv, "MATERIAL")
        rec["steps"].append({"when": tag, "solid_ms": s, "material_ms": m})
        log(f"{tag}: solid {s} ms / material {m} ms")

    note("render前")
    r1 = render_once()
    log("render 1 回目", r1, "s")
    note("render1回のあと")
    r2 = render_once()
    log("render 2 回目", r2, "s")
    note("render2回のあと")
    rec["render_sec"] = [r1, r2]

    if HOLD > 0:
        area.spaces.active.shading.type = "MATERIAL"
        log(f"描き続ける {HOLD} 秒(開始 {time.strftime('%H:%M:%S')})")
        n, t0 = 0, time.perf_counter()
        while time.perf_counter() - t0 < HOLD:
            piv.rotation_euler[2] = math.radians((n * 0.5) % 360)
            bpy.context.view_layer.update()
            with bpy.context.temp_override(window=win(), area=area, region=region):
                bpy.ops.wm.redraw_timer(type="DRAW", iterations=1)
            n += 1
        ms = (time.perf_counter() - t0) * 1000.0 / n
        rec["hold"] = {"seconds": HOLD, "frames": n, "ms_per_frame": round(ms, 2)}
        log(f"描き続けた: {n} コマ・1コマ {ms:.2f} ms(終了 {time.strftime('%H:%M:%S')})")

    p = os.path.join(OUT, f"order_effect_{STAGE}_n{GRID}.json")
    with open(p, "w", encoding="utf-8") as f:
        json.dump(rec, f, ensure_ascii=False, indent=1)
    log("wrote", p)
    bpy.context.preferences.view.use_save_prompt = False
    bpy.ops.wm.quit_blender()
    return None


bpy.app.timers.register(run, first_interval=3.0)

参考サイト

灰色の箱の街に何か1つだけ足すなら、まず「向きと色」から試してみてください。ノードは数個なのに、街路の景色がいちばん変わります 🏙️