diff --git a/.trellis/spec/blender/index.md b/.trellis/spec/blender/index.md index 1d9cb9b..42b98fc 100644 --- a/.trellis/spec/blender/index.md +++ b/.trellis/spec/blender/index.md @@ -37,7 +37,8 @@ │ osmassets/mesh.py MeshBatch 等几何构建 │ │ osmassets/materials.py 材质构建(消费 catalog 的声明) │ │ osmassets/tree.py 树实例化 │ -│ osmassets/water.py grass.py scrub.py 要素装配 │ +│ osmassets/water.py grass.py scrub.py │ +│ osmassets/building.py fountain.py 要素装配 │ │ │ │ generate_scene.py export_cesium.py 两个入口 │ │ tools/scene_digest.py 结构摘要工具 │ @@ -120,14 +121,15 @@ | 文件 | 行数 | 层 | |---|---|---| -| `generate_scene.py` | 999 | bpy · 入口 | -| `export_cesium.py` | 624 | bpy · 入口 | +| `generate_scene.py` | 899 | bpy · 入口 | +| `export_cesium.py` | 647 | bpy · 入口 | | `osmassets/tree.py` | 318 | bpy | | `osmassets/geom.py` | 241 | 纯 | -| `osmassets/materials.py` | 218 | bpy | -| `osmassets/catalog.py` | 195 | 纯 | +| `osmassets/materials.py` | 232 | bpy | +| `osmassets/catalog.py` | 197 | 纯 | | `osmassets/mesh.py` | 126 | bpy | | `osmassets/osm.py` | 89 | 纯 | +| `osmassets/building.py` / `fountain.py` | 56 / 49 | bpy | | `osmassets/water.py` / `grass.py` / `scrub.py` | 15 / 22 / 13 | bpy | | `tools/scene_digest.py` | 171 | bpy · 工具 | | `tests/test_pure.py` | 372 | 纯 · 测试 | diff --git a/.trellis/spec/blender/module-structure.md b/.trellis/spec/blender/module-structure.md index 89e1b0a..bdd79cd 100644 --- a/.trellis/spec/blender/module-structure.md +++ b/.trellis/spec/blender/module-structure.md @@ -24,7 +24,8 @@ osmassets/mesh.py MeshBatch、prism、polyline osmassets/materials.py 材质构建 osmassets/tree.py 树实例化 - osmassets/water.py grass.py scrub.py 要素装配 + osmassets/water.py grass.py scrub.py + osmassets/building.py fountain.py 要素装配 generate_scene.py export_cesium.py 两个入口脚本 tools/scene_digest.py 结构摘要工具 ``` @@ -51,6 +52,7 @@ | `materials.py` | bpy | 把 `catalog` 的规格变成真实材质:`from_spec()`、贴图、程序化噪声、tint、alpha-clip | | `tree.py` | bpy | 两个 vendored 模型 → 一套可实例化的运行时形状 | | `water.py` / `grass.py` / `scrub.py` | bpy | 单一 OSM 面要素的装配 | +| `building.py` | bpy | 单一 OSM 面要素 `building=*` 的装配 | | `fountain.py` | bpy | 单一 OSM 点要素 `amenity=fountain` 的装配 | ### `geom.py` 的两条隐含约定 @@ -95,6 +97,20 @@ def assemble(name, x, y, collection, materials): 创建三张 fountain 材质,并维护 `counts["fountain_count"]`。模块只负责在已投影 坐标上创建 basin / water / pedestal / spray objects,保持对象名和自定义属性不变。 +`building.py` 是面要素模块,但不裁剪 ring;入口在调用前已经过滤 +`len(ring) >= 3`,并且建筑要保持原始 OSM footprint 参与相机取景: + +```python +def assemble(ring, way_id, tag, office_overrides, collection, materials): + ... + return added, industrial_added, ring_pts +``` + +`generate_scene.py` 仍负责解析 `--office-overrides`、创建六张 building 材质、 +分发 `building=*` way、累加 `counts["building_count"]` / +`counts["industrial_count"]`,以及写 `scene["office_override_way_ids"]`。 +模块只负责 prism / roof / windows / bevel / building custom properties。 + ### 加一种新 OSM 要素 目标形态:**新增一个模块 + 注册一行,不改 `build()`**。 @@ -110,7 +126,7 @@ def assemble(name, x, y, collection, materials): ## 两个入口脚本 -| | `generate_scene.py` (999行) | `export_cesium.py` (624行) | +| | `generate_scene.py` (899行) | `export_cesium.py` (647行) | |---|---|---| | 调用 | `--background --factory-startup --python` | `--background --python` | | 输入 | `--osm` + `--geojson` | `--blend` | diff --git a/.trellis/spec/guides/artifact-parity-guide.md b/.trellis/spec/guides/artifact-parity-guide.md index 45732cb..3dabe00 100644 --- a/.trellis/spec/guides/artifact-parity-guide.md +++ b/.trellis/spec/guides/artifact-parity-guide.md @@ -163,7 +163,7 @@ capturedAt / durationMs / label |---|---|---| | P0 | 抽纯函数到 `osmassets/{osm,geom}.py` | ✅ 已完成 | | P1 | `catalog.py` 单一定义源 + `check_layers` | ✅ 已完成 | -| P2 | 要素注册表 | ⚠️ **部分**——`water/grass/scrub/tree/fountain.py` 已拆出,但**没有 `features/` 注册表**,`building` / `roads` 仍在 `generate_scene.py` 里 | +| P2 | 要素注册表 | ⚠️ **部分**——`water/grass/scrub/tree/fountain/building.py` 已拆出,但**没有 `features/` 注册表**,`roads` 仍在 `generate_scene.py` 里 | | P3 | 材质契约化(自定义属性传递 spec) | ✅ 已完成——`catalog.MATERIALS[*]["cesium"]` 经 `materials.from_spec()` 写入 `material["cesium_export"]`,`export_cesium.py` 优先读该属性;四张材质名表仅作旧 `.blend` 回退 | ### 已知缺陷(记录在案,本轮不修) diff --git a/.trellis/tasks/08-03-extract-building-module/check.jsonl b/.trellis/tasks/08-03-extract-building-module/check.jsonl new file mode 100644 index 0000000..aaa7c65 --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/check.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/blender/testing.md", "reason": "Defines pure test command and bpy-layer parity expectations for validation."} +{"file": ".trellis/spec/guides/artifact-parity-guide.md", "reason": "Defines compare contract and expected no-diff result for refactors."} +{"file": ".trellis/spec/blender/module-structure.md", "reason": "Defines dependency direction and safe import boundaries to review after new module creation."} diff --git a/.trellis/tasks/08-03-extract-building-module/design.md b/.trellis/tasks/08-03-extract-building-module/design.md new file mode 100644 index 0000000..353de1d --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/design.md @@ -0,0 +1,68 @@ +# Design + +## Architecture + +`building.py` will join the bpy-layer feature modules under +`blender/osmassets/`. It may import `MeshBatch`, `add_roof`, +`add_wall_panel`, and `make_prism` from `osmassets.mesh`, plus +`parse_height` from `osmassets.osm`. + +The module will expose: + +```python +def assemble(ring, way_id, tag, office_overrides, collection, materials): + ... +``` + +The function returns: + +```python +(added, industrial_added, ring_pts) +``` + +This keeps `generate_scene.py`'s count and focus-point handling unchanged while +removing building geometry construction from the entry script. + +## Boundaries + +- `generate_scene.py` keeps CLI parsing, OSM way dispatch, material creation, + count accumulation, focus-point accumulation, scene custom properties, and + stdout markers. +- `building.py` owns only building object creation and building-specific facade + details for a ring that has already been projected. +- `building.py` is a bpy-layer module. It must not be imported by pure Python + modules or pure unit tests. +- `catalog.py` remains the material declaration source; no material entries or + order change in this task. + +## Data Flow + +1. `generate_scene.py` parses `args["office_overrides"]` into a set. +2. `generate_scene.py` creates `building_mats` from `catalog.MATERIALS`. +3. OSM ways are projected to local-meter rings in `generate_scene.py`. +4. For ways with a `building` tag and at least three ring points, + `building.assemble(ring, str(way["id"]), tag, args["office_overrides"], + buildings_c, building_mats)` creates the prism, roof, windows, custom + properties, and bevel. +5. `generate_scene.py` updates `counts["building_count"]`, + `counts["industrial_count"]`, and `focus_points` from the return tuple. + +## Compatibility + +The move must preserve object names, mesh names, material assignments, bevel +modifier fields, custom properties, window-band geometry, and return values. +Cesium export consumes the resulting `.blend`, so parity should see no +non-ignored contract difference. + +## Trade-Offs + +The `office_overrides` set is passed into `building.assemble()` rather than +passing the whole `args` dict. This narrows the module contract while preserving +behavior. A full feature registry remains deferred until building and roads are +both isolated enough to compare safely. + +## Rollback + +Rollback is mechanical: move `assemble()` and its helper back into +`generate_scene.py`, restore direct calls, remove the `building.py` import, and +delete the module. diff --git a/.trellis/tasks/08-03-extract-building-module/implement.jsonl b/.trellis/tasks/08-03-extract-building-module/implement.jsonl new file mode 100644 index 0000000..b8c1ba2 --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/blender/module-structure.md", "reason": "Defines bpy-layer module boundaries and feature module responsibilities for moving building assembly."} +{"file": ".trellis/spec/blender/asset-generation.md", "reason": "Defines MeshBatch, material order, deterministic generation, and Cesium material constraints relevant to building assembly."} +{"file": ".trellis/spec/guides/artifact-parity-guide.md", "reason": "Defines mandatory before/after parity validation for pure Blender scene refactors."} diff --git a/.trellis/tasks/08-03-extract-building-module/implement.md b/.trellis/tasks/08-03-extract-building-module/implement.md new file mode 100644 index 0000000..ed31bbf --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/implement.md @@ -0,0 +1,47 @@ +# Implementation Plan + +## Checklist + +1. Capture a parity baseline before product-code edits. +2. Add `blender/osmassets/building.py` with the current building assembly and + detail helper logic. +3. Import `building.py` in `blender/generate_scene.py`. +4. Remove inline `_assemble_building()` and `add_building_details()` from + `generate_scene.py`. +5. Replace the building call site with `building.assemble(...)`, passing + `args["office_overrides"]` instead of the full `args` dict. +6. Run Python validation: + - `python3 -m py_compile blender/osmassets/building.py` + - `python3 -m py_compile blender/generate_scene.py` + - `python3 -m unittest blender/tests/test_pure.py` +7. Capture parity after the refactor. +8. Compare before/after parity snapshots and inspect any non-ignored diff. +9. Update `.trellis/spec` and `docs/changelog.md` only for durable status or + convention changes discovered during implementation. +10. Commit, archive the task, and record the journal entry. + +## Validation Commands + +```bash +node scripts/parity.js capture building-module-before --stages blender,cesium +python3 -m py_compile blender/osmassets/building.py +python3 -m py_compile blender/generate_scene.py +python3 -m unittest blender/tests/test_pure.py +node scripts/parity.js capture building-module-after --stages blender,cesium +node scripts/parity.js compare building-module-before building-module-after +``` + +## Risk Points + +- Passing `office_overrides` instead of `args` changes the function contract; + verify `office_override` custom properties and industrial classification via + parity. +- `Building__Windows` mesh construction depends on `MeshBatch` object + naming and `add_wall_panel()` geometry; copy it mechanically. +- `make_prism()` can return `None` on degenerate input, but the current call + site filters `len(ring) >= 3`. Preserve the existing behavior and return + tuple shape. +- Do not reorder building material creation or catalog entries; material order + affects GLB material indices. +- Parity is mandatory because this is a pure refactor of bpy-layer scene + generation. diff --git a/.trellis/tasks/08-03-extract-building-module/prd.md b/.trellis/tasks/08-03-extract-building-module/prd.md new file mode 100644 index 0000000..950e3fc --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/prd.md @@ -0,0 +1,93 @@ +# Extract building module + +## Goal + +Move building assembly out of `blender/generate_scene.py` into a dedicated +bpy-layer module, keeping generated `.blend` / `.glb` / metadata structure +unchanged. This continues the documented P2 feature-module direction after +`fountain.py`. + +## Background + +- `.trellis/spec/guides/artifact-parity-guide.md` records P2 as partial: + `water.py`, `grass.py`, `scrub.py`, `tree.py`, and `fountain.py` are already + split out, while `building` and `roads` still live in `generate_scene.py`. +- `blender/generate_scene.py:72` defines `_assemble_building()`. +- `blender/generate_scene.py:179` defines `add_building_details()`, which + creates office / industrial window bands with `MeshBatch` and + `add_wall_panel()`. +- `blender/generate_scene.py:789` dispatches OSM ways with a `building` tag, + increments `counts["building_count"]` and `counts["industrial_count"]`, and + extends `focus_points` with the returned ring. +- `blender/generate_scene.py:710` creates `building_mats` from the existing + `catalog.MATERIALS["building_" + key]` entries. +- `blender/generate_scene.py:910` persists `office_override_way_ids` as a scene + custom property from `args["office_overrides"]`. + +## Requirements + +1. Add a new bpy-layer module at `blender/osmassets/building.py`. +2. Move the current building assembly behavior into the new module: + - `_assemble_building()` + - `add_building_details()` +3. Expose an `assemble(...)` function from the new module that returns the same + tuple shape currently returned by `_assemble_building()`: + `(added, industrial_added, ring_pts)`. +4. Keep `generate_scene.py` responsible for: + - parsing `--office-overrides`; + - creating `building_mats`; + - filtering OSM ways with a `building` tag and `len(ring) >= 3`; + - updating `building_count`, `industrial_count`, and `focus_points`; + - writing `scene["office_override_way_ids"]`; + - writing the existing `SCENE_DONE` JSON shape. +5. Preserve existing building object names and mesh names: + - `Building_` + - `Building__Roof` + - `Building__Windows` +6. Preserve existing building custom properties: + - `osm_height` + - `render_height` + - `building_kind` + - `osm_building_tag` + - `office_override` +7. Preserve current height rules: + - industrial buildings use the parsed source height; + - non-industrial buildings under 30m render at 11.4m; + - source height has a 3.0m floor and `parse_height(tag, 12.0)` default. +8. Preserve current facade behavior: + - industrial buildings use one high horizontal band; + - office buildings use floor-based window bands; + - bevel modifier name, width, and segment count stay unchanged. +9. Do not introduce a full `features/` registry in this task. +10. Do not modify roads, material definitions, `MATERIALS` order, + `ROAD_LAYERS` order, stdout markers, parity ignore lists, or known + unrelated defects D1-D3. + +## Acceptance Criteria + +- [x] `blender/osmassets/building.py` contains building assembly behavior and + imports only bpy-layer-safe dependencies. +- [x] `blender/generate_scene.py` imports and calls the new building module; no + inline `_assemble_building()` or `add_building_details()` remains there. +- [x] Building counts, industrial counts, focus point behavior, scene custom + properties, and `SCENE_DONE` JSON keys remain unchanged. +- [x] `python3 -m py_compile blender/osmassets/building.py` passes. +- [x] `python3 -m py_compile blender/generate_scene.py` passes. +- [x] `python3 -m unittest blender/tests/test_pure.py` passes. +- [x] A before/after parity comparison is run for the Blender/Cesium stages and + any non-ignored diff is either absent or explicitly explained as + expected. For this refactor, the expected result is no contract diff. + +## Out Of Scope + +- Full feature registry implementation. +- Road extraction. +- Building visual redesign. +- Building material or Cesium export contract changes. +- Moving CLI parsing or `office_override_way_ids` scene metadata out of + `generate_scene.py`. +- Changing geometry helper APIs in `osmassets.mesh`. + +## Open Questions + +None. diff --git a/.trellis/tasks/08-03-extract-building-module/task.json b/.trellis/tasks/08-03-extract-building-module/task.json new file mode 100644 index 0000000..e1bea36 --- /dev/null +++ b/.trellis/tasks/08-03-extract-building-module/task.json @@ -0,0 +1,26 @@ +{ + "id": "extract-building-module", + "name": "extract-building-module", + "title": "Extract building module", + "description": "Move building assembly out of blender/generate_scene.py into a dedicated bpy-layer module without changing generated artifacts.", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-03", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/blender/generate_scene.py b/blender/generate_scene.py index 65a79d0..030b9f3 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -52,12 +52,10 @@ from osmassets.materials import ( # noqa: E402 from osmassets.mesh import ( # noqa: E402 MeshBatch, add_polyline, - add_roof, - add_wall_panel, - make_prism, new_collection, ) from osmassets.osm import Projector, parse_height, parse_osm # noqa: E402 +from osmassets import building as _building # noqa: E402 from osmassets import fountain as _fountain # noqa: E402 from osmassets import water as _water # noqa: E402 from osmassets import grass as _grass # noqa: E402 @@ -65,35 +63,6 @@ from osmassets import scrub as _scrub # noqa: E402 from osmassets import tree as _tree # noqa: E402 -# Building assembly stays in this file because it needs make_prism, add_roof, -# add_wall_panel, and add_building_details — Blender geometry helpers that -# live a few lines above. The other feature assembly code lives under -# osmassets/ and keeps build() focused on data dispatch, materials, and counts. -def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats): - industrial = (tag.get("building") == "industrial" and - way_id not in args["office_overrides"]) - source_height = max(3.0, parse_height(tag, 12.0)) - height = source_height if industrial or source_height >= 30.0 else 11.4 - material = building_mats["industrial"] if industrial else building_mats["default"] - building_name = "Building_" + way_id - building_obj = make_prism(building_name, ring, 0.08, height, - material, buildings_c) - if building_obj: - building_obj["osm_height"] = source_height - building_obj["render_height"] = height - building_obj["building_kind"] = "industrial" if industrial else "office" - building_obj["osm_building_tag"] = tag.get("building", "") - building_obj["office_override"] = way_id in args["office_overrides"] - bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL") - bevel.width = 0.16 - bevel.segments = 2 - roof_mat = (building_mats["industrial_roof"] if industrial - else building_mats["office_roof"]) - add_roof(building_name, ring, height + 0.095, roof_mat, buildings_c) - add_building_details(building_name, ring, height, industrial, - building_mats, buildings_c) - return 1, int(industrial), ring - MODEL_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "models", "polyhaven" )) @@ -176,34 +145,6 @@ def cli_args(): return values -def add_building_details(name, ring, height, industrial, materials, collection): - footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring - if len(footprint) < 3: - return - glass_mat = materials["factory_glass"] if industrial else materials["glass"] - - glass_batch = MeshBatch(name + "_Windows", collection, glass_mat) - edges = list(zip(footprint, footprint[1:] + footprint[:1])) - if industrial: - band_height = min(1.8, max(0.75, height * 0.16)) - band_base = max(0.9, height * 0.52) - for start, end in edges: - add_wall_panel(glass_batch, start, end, band_base, band_height, - thickness=0.055, inset=0.12) - else: - floor_height = 3.25 - floor_count = max(1, int((height - 0.7) / floor_height)) - for floor in range(floor_count): - band_base = 0.55 + floor * floor_height + 0.95 - if band_base + 1.25 > height - 0.18: - break - for start, end in edges: - add_wall_panel(glass_batch, start, end, band_base, 1.25, - thickness=0.045, inset=0.10) - glass_batch.finish() - - - def add_geojson_layer(path, layer, projector, collection, material, z): if not os.path.exists(path): return 0 @@ -787,8 +728,9 @@ def build(args): tree_rows.append((ring, tag)) focus_points.extend(ring) elif "building" in tag and len(ring) >= 3: - added, ind_added, ring_pts = _assemble_building( - ring, str(way["id"]), tag, args, buildings_c, building_mats) + added, ind_added, ring_pts = _building.assemble( + ring, str(way["id"]), tag, args["office_overrides"], + buildings_c, building_mats) counts["building_count"] += added counts["industrial_count"] += ind_added if ring_pts: diff --git a/blender/osmassets/building.py b/blender/osmassets/building.py new file mode 100644 index 0000000..93e4a51 --- /dev/null +++ b/blender/osmassets/building.py @@ -0,0 +1,56 @@ +"""Building feature assembly (`building=*`).""" + +from osmassets.mesh import MeshBatch, add_roof, add_wall_panel, make_prism +from osmassets.osm import parse_height + + +def add_details(name, ring, height, industrial, materials, collection): + footprint = ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring + if len(footprint) < 3: + return + glass_mat = materials["factory_glass"] if industrial else materials["glass"] + + glass_batch = MeshBatch(name + "_Windows", collection, glass_mat) + edges = list(zip(footprint, footprint[1:] + footprint[:1])) + if industrial: + band_height = min(1.8, max(0.75, height * 0.16)) + band_base = max(0.9, height * 0.52) + for start, end in edges: + add_wall_panel(glass_batch, start, end, band_base, band_height, + thickness=0.055, inset=0.12) + else: + floor_height = 3.25 + floor_count = max(1, int((height - 0.7) / floor_height)) + for floor in range(floor_count): + band_base = 0.55 + floor * floor_height + 0.95 + if band_base + 1.25 > height - 0.18: + break + for start, end in edges: + add_wall_panel(glass_batch, start, end, band_base, 1.25, + thickness=0.045, inset=0.10) + glass_batch.finish() + + +def assemble(ring, way_id, tag, office_overrides, collection, materials): + industrial = (tag.get("building") == "industrial" and + way_id not in office_overrides) + source_height = max(3.0, parse_height(tag, 12.0)) + height = source_height if industrial or source_height >= 30.0 else 11.4 + material = materials["industrial"] if industrial else materials["default"] + building_name = "Building_" + way_id + building_obj = make_prism(building_name, ring, 0.08, height, + material, collection) + if building_obj: + building_obj["osm_height"] = source_height + building_obj["render_height"] = height + building_obj["building_kind"] = "industrial" if industrial else "office" + building_obj["osm_building_tag"] = tag.get("building", "") + building_obj["office_override"] = way_id in office_overrides + bevel = building_obj.modifiers.new("Soft facade edges", "BEVEL") + bevel.width = 0.16 + bevel.segments = 2 + roof_mat = (materials["industrial_roof"] if industrial + else materials["office_roof"]) + add_roof(building_name, ring, height + 0.095, roof_mat, collection) + add_details(building_name, ring, height, industrial, materials, collection) + return 1, int(industrial), ring diff --git a/docs/changelog.md b/docs/changelog.md index 1f8b051..eda6c54 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,10 @@ 保留点要素过滤、投影、材质创建和计数。Blender/Cesium parity before/after 在 `nantaizi-lake-innovation-valley` 与 `hanyang-block` 两个样本上均 identical;P2 剩余未拆出部分更新为 `building` / `roads`。 +- 抽出 building 要素装配:新增 `blender/osmassets/building.py`,迁移 building prism、 + roof、window bands、bevel 和 building custom properties;`generate_scene.py` 继续 + 负责 `office_overrides`、材质创建、way 分发、计数和 scene metadata。Blender/Cesium + parity before/after 在两个样本上均 identical;P2 剩余未拆出部分更新为 `roads`。 ## 2026-07-31(三)远看发黑的真正原因:反照率没被提亮