Files
Construction-project-master/docs/superpowers/plans/2026-04-04-birdseye-view.md
2026-04-04 18:38:12 +09:00

1312 lines
40 KiB
Markdown

# Bird's-Eye View Generation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add 3D bird's-eye view and perspective rendering to CivilPlan MCP using Google's Nano Banana Pro (Gemini 3 Pro Image) API, bump version to 2.0.0, and create a polished release with comprehensive documentation.
**Architecture:** New MCP tool `civilplan_generate_birdseye_view` calls the Gemini API via `google-genai` SDK. A service layer wraps API calls; prompt templates are specialized per project domain (road/building/water/river/landscape). SVG drawings from the existing generator can optionally feed in as reference images via `cairosvg`.
**Tech Stack:** google-genai, cairosvg, Pillow, FastMCP 2.0+, Python 3.11+
---
### Task 1: Add GEMINI_API_KEY to Configuration
**Files:**
- Modify: `civilplan_mcp/config.py`
- Modify: `.env.example`
- Test: `tests/test_config_and_secure_store.py`
- [ ] **Step 1: Write failing test for GEMINI_API_KEY in settings**
Add to `tests/test_config_and_secure_store.py`:
```python
def test_settings_has_gemini_api_key():
from civilplan_mcp.config import Settings
s = Settings()
assert hasattr(s, "gemini_api_key")
assert s.gemini_api_key == ""
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v`
Expected: FAIL — `Settings` has no `gemini_api_key` attribute
- [ ] **Step 3: Add gemini_api_key to Settings and get_settings()**
In `civilplan_mcp/config.py`, add field to `Settings` class:
```python
class Settings(BaseModel):
# ... existing fields ...
gemini_api_key: str = Field(default_factory=lambda: os.getenv("GEMINI_API_KEY", ""))
```
In `get_settings()`, add after the existing vworld block:
```python
if not settings.gemini_api_key:
settings.gemini_api_key = secure_keys.get("GEMINI_API_KEY", "")
```
In `check_api_keys()`, add:
```python
if not settings.gemini_api_key:
missing.append("GEMINI_API_KEY")
```
- [ ] **Step 4: Update .env.example**
Append to `.env.example`:
```
GEMINI_API_KEY=
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_config_and_secure_store.py::test_settings_has_gemini_api_key -v`
Expected: PASS
- [ ] **Step 6: Run full test suite to check nothing broke**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v`
Expected: All existing tests PASS
- [ ] **Step 7: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/config.py .env.example tests/test_config_and_secure_store.py
git commit -m "feat: add GEMINI_API_KEY to config system"
```
---
### Task 2: Create Gemini Image Service
**Files:**
- Create: `civilplan_mcp/services/__init__.py`
- Create: `civilplan_mcp/services/gemini_image.py`
- Test: `tests/test_gemini_image.py`
- [ ] **Step 1: Write failing tests for GeminiImageService**
Create `tests/test_gemini_image.py`:
```python
from __future__ import annotations
import base64
from unittest.mock import MagicMock, patch
import pytest
def test_generate_image_returns_dict():
from civilplan_mcp.services.gemini_image import GeminiImageService
svc = GeminiImageService(api_key="test-key")
assert svc.api_key == "test-key"
assert svc.model == "gemini-3-pro-image-preview"
def test_generate_image_calls_api(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
# Create a fake 1x1 white PNG
import io
from PIL import Image as PILImage
img = PILImage.new("RGB", (1, 1), "white")
buf = io.BytesIO()
img.save(buf, format="PNG")
fake_png_bytes = buf.getvalue()
mock_image = MagicMock()
mock_image.save = MagicMock()
mock_part = MagicMock()
mock_part.text = None
mock_part.inline_data = MagicMock()
mock_part.as_image.return_value = mock_image
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "test.png"),
aspect_ratio="16:9",
image_size="2K",
)
assert result["status"] == "success"
assert result["path"] == str(tmp_path / "test.png")
mock_image.save.assert_called_once_with(str(tmp_path / "test.png"))
def test_generate_image_with_reference(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
mock_image = MagicMock()
mock_image.save = MagicMock()
mock_part = MagicMock()
mock_part.text = None
mock_part.inline_data = MagicMock()
mock_part.as_image.return_value = mock_image
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
# Create a small reference image
from PIL import Image as PILImage
ref_path = tmp_path / "ref.png"
PILImage.new("RGB", (10, 10), "gray").save(str(ref_path))
result = svc.generate_image(
prompt="test prompt with reference",
output_path=str(tmp_path / "out.png"),
reference_image_path=str(ref_path),
)
assert result["status"] == "success"
# Verify contents list included the reference image
call_args = mock_client.models.generate_content.call_args
contents = call_args.kwargs.get("contents") or call_args[1].get("contents")
assert len(contents) == 2 # prompt + image
def test_generate_image_api_failure(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.side_effect = Exception("API error")
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "fail.png"),
)
assert result["status"] == "error"
assert "API error" in result["error"]
def test_generate_image_no_image_in_response(tmp_path):
from civilplan_mcp.services.gemini_image import GeminiImageService
mock_part = MagicMock()
mock_part.text = "Sorry, I cannot generate that image."
mock_part.inline_data = None
mock_response = MagicMock()
mock_response.parts = [mock_part]
with patch("civilplan_mcp.services.gemini_image.genai") as mock_genai:
mock_client = MagicMock()
mock_genai.Client.return_value = mock_client
mock_client.models.generate_content.return_value = mock_response
svc = GeminiImageService(api_key="test-key")
result = svc.generate_image(
prompt="test prompt",
output_path=str(tmp_path / "noimg.png"),
)
assert result["status"] == "error"
assert "no image" in result["error"].lower()
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v`
Expected: FAIL — module `civilplan_mcp.services.gemini_image` not found
- [ ] **Step 3: Create services package and gemini_image.py**
Create `civilplan_mcp/services/__init__.py`:
```python
```
Create `civilplan_mcp/services/gemini_image.py`:
```python
from __future__ import annotations
import logging
from pathlib import Path
from google import genai
from google.genai import types
from PIL import Image
logger = logging.getLogger(__name__)
class GeminiImageService:
def __init__(self, api_key: str, model: str = "gemini-3-pro-image-preview"):
self.api_key = api_key
self.model = model
self._client = genai.Client(api_key=api_key)
def generate_image(
self,
*,
prompt: str,
output_path: str,
reference_image_path: str | None = None,
aspect_ratio: str = "16:9",
image_size: str = "2K",
) -> dict:
try:
contents: list = [prompt]
if reference_image_path:
ref_img = Image.open(reference_image_path)
contents.append(ref_img)
config = types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
image_size=image_size,
),
)
response = self._client.models.generate_content(
model=self.model,
contents=contents,
config=config,
)
for part in response.parts:
if part.inline_data is not None:
image = part.as_image()
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
image.save(output_path)
return {"status": "success", "path": output_path}
return {"status": "error", "error": "No image in API response"}
except Exception as exc:
logger.error("Gemini image generation failed: %s", exc)
return {"status": "error", "error": str(exc)}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_gemini_image.py -v`
Expected: All 5 tests PASS
- [ ] **Step 5: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/services/__init__.py civilplan_mcp/services/gemini_image.py tests/test_gemini_image.py
git commit -m "feat: add Gemini image service for Nano Banana Pro API"
```
---
### Task 3: Create Prompt Templates
**Files:**
- Create: `civilplan_mcp/prompts/__init__.py`
- Create: `civilplan_mcp/prompts/birdseye_templates.py`
- Test: `tests/test_birdseye_templates.py`
- [ ] **Step 1: Write failing tests for prompt templates**
Create `tests/test_birdseye_templates.py`:
```python
from __future__ import annotations
import pytest
def test_build_birdseye_prompt_road():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="birdseye",
project_type="road",
project_summary="경기도 지방도 890m, 폭 6m, 2차선 아스팔트 도로",
details={"length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스콘"},
)
assert "bird's-eye view" in prompt.lower() or "aerial" in prompt.lower()
assert "890" in prompt
assert "road" in prompt.lower() or "도로" in prompt
def test_build_perspective_prompt_building():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="perspective",
project_type="building",
project_summary="서울시 강남구 5층 오피스 빌딩",
details={"floors": 5, "use": "오피스"},
)
assert "perspective" in prompt.lower()
assert "building" in prompt.lower() or "빌딩" in prompt
def test_all_project_types_have_templates():
from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS
expected = {"road", "building", "water", "river", "landscape", "mixed"}
assert set(DOMAIN_PROMPTS.keys()) == expected
def test_build_prompt_unknown_type_uses_mixed():
from civilplan_mcp.prompts.birdseye_templates import build_prompt
prompt = build_prompt(
view_type="birdseye",
project_type="unknown_type",
project_summary="Test project",
details={},
)
assert isinstance(prompt, str)
assert len(prompt) > 50
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v`
Expected: FAIL — module not found
- [ ] **Step 3: Create prompts package and templates**
Create `civilplan_mcp/prompts/__init__.py`:
```python
```
Create `civilplan_mcp/prompts/birdseye_templates.py`:
```python
from __future__ import annotations
from typing import Any
DOMAIN_PROMPTS: dict[str, str] = {
"road": (
"Focus on the road alignment cutting through the terrain. "
"Show road surface markings, lane dividers, shoulders, drainage ditches along both sides, "
"utility corridors (water/sewer pipes if applicable), guard rails, and traffic signage. "
"Include surrounding land use context: rice paddies, hills, or residential areas typical of Korean rural/suburban settings."
),
"building": (
"Focus on the building mass, facade materials, and rooftop details. "
"Show the site context including parking areas, landscaping, pedestrian paths, and adjacent roads. "
"Include typical Korean urban context: neighboring buildings, street trees, and utility poles."
),
"water": (
"Focus on the pipeline route and treatment facility layout. "
"Show pipe trenches, manholes at regular intervals, pump stations, and connection points. "
"Include cross-section hints showing pipe burial depth. "
"Surrounding context: Korean suburban or rural terrain with roads running alongside."
),
"river": (
"Focus on the riverbank improvements, embankment structures, and flood control elements. "
"Show gabion walls, riprap, walking paths along the levee, bridge crossings, and flood gates. "
"Include the water surface with natural flow patterns and riparian vegetation."
),
"landscape": (
"Focus on the green space design, vegetation patterns, and hardscape elements. "
"Show walking trails, rest areas with benches, playground equipment, water features, "
"and ornamental planting beds. Include seasonal Korean trees (cherry blossom, pine, maple)."
),
"mixed": (
"Show a comprehensive development site with multiple infrastructure elements. "
"Include roads, buildings, utility corridors, and landscaped areas working together. "
"Emphasize how different systems connect and interact within the Korean development context."
),
}
VIEW_INSTRUCTIONS: dict[str, str] = {
"birdseye": (
"Create a photorealistic aerial bird's-eye view rendering, "
"camera angle approximately 45-60 degrees from above, "
"showing the full project extent with surrounding context."
),
"perspective": (
"Create a photorealistic eye-level perspective rendering, "
"camera positioned at human eye height (1.6m), "
"showing the project from the most representative viewpoint."
),
}
def build_prompt(
*,
view_type: str,
project_type: str,
project_summary: str,
details: dict[str, Any],
) -> str:
view_instruction = VIEW_INSTRUCTIONS.get(view_type, VIEW_INSTRUCTIONS["birdseye"])
domain_context = DOMAIN_PROMPTS.get(project_type, DOMAIN_PROMPTS["mixed"])
detail_lines = []
for key, value in details.items():
if value is not None:
detail_lines.append(f"- {key}: {value}")
detail_block = "\n".join(detail_lines) if detail_lines else "No additional details."
return (
f"{view_instruction}\n\n"
f"Project description: {project_summary}\n\n"
f"Technical details:\n{detail_block}\n\n"
f"Domain-specific guidance:\n{domain_context}\n\n"
f"Style: Professional architectural visualization for a Korean construction project plan. "
f"Clear daytime weather, natural lighting, high detail. "
f"Include a subtle north arrow and scale reference in the corner."
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_templates.py -v`
Expected: All 4 tests PASS
- [ ] **Step 5: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/prompts/__init__.py civilplan_mcp/prompts/birdseye_templates.py tests/test_birdseye_templates.py
git commit -m "feat: add project-type-specific prompt templates for birdseye rendering"
```
---
### Task 4: Create Bird's-Eye View Generator Tool
**Files:**
- Create: `civilplan_mcp/tools/birdseye_generator.py`
- Test: `tests/test_birdseye_generator.py`
- [ ] **Step 1: Write failing tests**
Create `tests/test_birdseye_generator.py`:
```python
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
SAMPLE_PROJECT_SPEC = {
"project_id": "PRJ-20260404-001",
"domain": "토목_도로",
"project_type": ["도로"],
"road": {"class": "소로3류", "length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스콘"},
"terrain": "구릉",
"region": "경기도",
"year_start": 2026,
"year_end": 2028,
"utilities": ["상수도", "하수도"],
}
DOMAIN_MAP = {
"토목_도로": "road",
"건축": "building",
"토목_상하수도": "water",
"토목_하천": "river",
"조경": "landscape",
"복합": "mixed",
}
def _mock_generate_image(**kwargs):
return {"status": "success", "path": kwargs.get("output_path", "/tmp/test.png")}
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_returns_two_images(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = lambda **kw: {
"status": "success",
"path": kw["output_path"],
}
mock_svc_cls.return_value = mock_svc
result = generate_birdseye_view(
project_summary="경기도 지방도 890m 도로",
project_spec=SAMPLE_PROJECT_SPEC,
)
assert result["status"] == "success"
assert "birdseye_view" in result
assert "perspective_view" in result
assert result["birdseye_view"]["status"] == "success"
assert result["perspective_view"]["status"] == "success"
assert mock_svc.generate_image.call_count == 2
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_with_svg_reference(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = lambda **kw: {
"status": "success",
"path": kw["output_path"],
}
mock_svc_cls.return_value = mock_svc
svg_content = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100" fill="gray"/></svg>'
with patch("civilplan_mcp.tools.birdseye_generator.svg_to_png") as mock_svg:
mock_svg.return_value = str(tmp_path / "ref.png")
# Create fake ref.png so path exists
(tmp_path / "ref.png").write_bytes(b"\x89PNG\r\n")
result = generate_birdseye_view(
project_summary="경기도 도로",
project_spec=SAMPLE_PROJECT_SPEC,
svg_drawing=svg_content,
)
assert result["status"] == "success"
mock_svg.assert_called_once()
# Both calls should include reference_image_path
for call in mock_svc.generate_image.call_args_list:
assert call.kwargs.get("reference_image_path") is not None
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
def test_generate_birdseye_no_api_key(mock_settings):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(gemini_api_key="")
result = generate_birdseye_view(
project_summary="Test",
project_spec=SAMPLE_PROJECT_SPEC,
)
assert result["status"] == "error"
assert "GEMINI_API_KEY" in result["error"]
@patch("civilplan_mcp.tools.birdseye_generator.get_settings")
@patch("civilplan_mcp.tools.birdseye_generator.GeminiImageService")
def test_generate_birdseye_partial_failure(mock_svc_cls, mock_settings, tmp_path):
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
mock_settings.return_value = MagicMock(
gemini_api_key="test-key",
output_dir=tmp_path,
)
call_count = [0]
def side_effect(**kw):
call_count[0] += 1
if call_count[0] == 1:
return {"status": "success", "path": kw["output_path"]}
return {"status": "error", "error": "Rate limit exceeded"}
mock_svc = MagicMock()
mock_svc.generate_image.side_effect = side_effect
mock_svc_cls.return_value = mock_svc
result = generate_birdseye_view(
project_summary="Test",
project_spec=SAMPLE_PROJECT_SPEC,
)
# Should still return partial results
assert result["status"] == "partial"
assert result["birdseye_view"]["status"] == "success"
assert result["perspective_view"]["status"] == "error"
def test_domain_to_project_type_mapping():
from civilplan_mcp.tools.birdseye_generator import _domain_to_project_type
assert _domain_to_project_type("토목_도로") == "road"
assert _domain_to_project_type("건축") == "building"
assert _domain_to_project_type("토목_상하수도") == "water"
assert _domain_to_project_type("토목_하천") == "river"
assert _domain_to_project_type("조경") == "landscape"
assert _domain_to_project_type("복합") == "mixed"
assert _domain_to_project_type("unknown") == "mixed"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v`
Expected: FAIL — module not found
- [ ] **Step 3: Create birdseye_generator.py**
Create `civilplan_mcp/tools/birdseye_generator.py`:
```python
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any
from civilplan_mcp.config import get_settings
from civilplan_mcp.models import ProjectDomain
from civilplan_mcp.prompts.birdseye_templates import build_prompt
from civilplan_mcp.services.gemini_image import GeminiImageService
from civilplan_mcp.tools._base import wrap_response
logger = logging.getLogger(__name__)
DOMAIN_MAP: dict[str, str] = {
"토목_도로": "road",
"건축": "building",
"토목_상하수도": "water",
"토목_하천": "river",
"조경": "landscape",
"복합": "mixed",
}
def _domain_to_project_type(domain: str) -> str:
return DOMAIN_MAP.get(domain, "mixed")
def svg_to_png(svg_content: str, output_path: str) -> str:
import cairosvg
cairosvg.svg2png(bytestring=svg_content.encode("utf-8"), write_to=output_path)
return output_path
def generate_birdseye_view(
*,
project_summary: str,
project_spec: dict[str, Any],
svg_drawing: str | None = None,
resolution: str = "2K",
output_dir: str | None = None,
) -> dict[str, Any]:
"""Generate bird's-eye view and perspective renderings using Nano Banana Pro."""
settings = get_settings()
if not settings.gemini_api_key:
return {
"status": "error",
"error": "GEMINI_API_KEY is not configured. Set it in .env or run setup_keys.py.",
}
out = Path(output_dir or settings.output_dir) / "renders"
out.mkdir(parents=True, exist_ok=True)
domain = project_spec.get("domain", "복합")
project_type = _domain_to_project_type(domain)
project_id = project_spec.get("project_id", "unknown")
# Extract details for prompt
details: dict[str, Any] = {}
road = project_spec.get("road")
if road:
details.update({k: v for k, v in road.items() if v is not None})
details["terrain"] = project_spec.get("terrain")
details["region"] = project_spec.get("region")
details["utilities"] = project_spec.get("utilities")
# Convert SVG to PNG reference if provided
ref_path: str | None = None
if svg_drawing:
try:
ref_png = str(out / f"{project_id}_ref.png")
ref_path = svg_to_png(svg_drawing, ref_png)
except Exception as exc:
logger.warning("SVG to PNG conversion failed, proceeding without reference: %s", exc)
ref_path = None
svc = GeminiImageService(api_key=settings.gemini_api_key)
results: dict[str, Any] = {}
for view_type in ("birdseye", "perspective"):
prompt = build_prompt(
view_type=view_type,
project_type=project_type,
project_summary=project_summary,
details=details,
)
file_path = str(out / f"{project_id}_{view_type}.png")
result = svc.generate_image(
prompt=prompt,
output_path=file_path,
reference_image_path=ref_path,
aspect_ratio="16:9",
image_size=resolution,
)
results[f"{view_type}_view"] = result
birdseye_ok = results["birdseye_view"].get("status") == "success"
perspective_ok = results["perspective_view"].get("status") == "success"
if birdseye_ok and perspective_ok:
status = "success"
elif birdseye_ok or perspective_ok:
status = "partial"
else:
status = "error"
domain_enum = ProjectDomain(domain) if domain in ProjectDomain.__members__.values() else ProjectDomain.복합
return wrap_response(
{
"status": status,
"project_id": project_id,
"model": "nano-banana-pro (gemini-3-pro-image-preview)",
"resolution": resolution,
"birdseye_view": results["birdseye_view"],
"perspective_view": results["perspective_view"],
},
domain_enum,
)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_birdseye_generator.py -v`
Expected: All 5 tests PASS
- [ ] **Step 5: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/tools/birdseye_generator.py tests/test_birdseye_generator.py
git commit -m "feat: add birdseye view generator MCP tool"
```
---
### Task 5: Register Tool in Server and Update Dependencies
**Files:**
- Modify: `civilplan_mcp/server.py`
- Modify: `civilplan_mcp/__init__.py`
- Modify: `requirements.txt`
- Modify: `pyproject.toml`
- Test: `tests/test_server_registration.py`
- [ ] **Step 1: Write failing test for new tool registration**
Add to `tests/test_server_registration.py`:
```python
def test_birdseye_tool_registered():
from civilplan_mcp.server import build_mcp
app = build_mcp()
tool_names = [t.name for t in app._tool_manager.tools.values()]
assert "civilplan_generate_birdseye_view" in tool_names
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py::test_birdseye_tool_registered -v`
Expected: FAIL — tool not registered
- [ ] **Step 3: Register tool in server.py**
Add import at top of `civilplan_mcp/server.py`:
```python
from civilplan_mcp.tools.birdseye_generator import generate_birdseye_view
```
Add registration in `build_mcp()` after the last `_register_write_tool` call:
```python
_register_write_tool(app, "civilplan_generate_birdseye_view", generate_birdseye_view)
```
- [ ] **Step 4: Update version to 2.0.0**
In `civilplan_mcp/__init__.py`:
```python
__all__ = ["__version__"]
__version__ = "2.0.0"
```
In `pyproject.toml`, change:
```toml
version = "2.0.0"
```
In `civilplan_mcp/config.py`, change:
```python
version: str = "2.0.0"
```
- [ ] **Step 5: Update dependencies**
In `requirements.txt`, add before `pytest`:
```
google-genai>=1.0.0
cairosvg>=2.7.0
Pillow>=10.0.0
```
In `pyproject.toml`, add to dependencies list:
```toml
"google-genai>=1.0.0",
"cairosvg>=2.7.0",
"Pillow>=10.0.0",
```
- [ ] **Step 6: Install new dependencies**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && pip install google-genai cairosvg Pillow`
- [ ] **Step 7: Run test to verify registration passes**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/test_server_registration.py -v`
Expected: PASS (including the new test)
- [ ] **Step 8: Run full test suite**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v`
Expected: All tests PASS
- [ ] **Step 9: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add civilplan_mcp/server.py civilplan_mcp/__init__.py civilplan_mcp/config.py requirements.txt pyproject.toml tests/test_server_registration.py
git commit -m "feat: register birdseye tool, bump to v2.0.0, add new dependencies"
```
---
### Task 6: Write Comprehensive README
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Read current README for structure reference**
Run: `cat C:/Users/sinmb/workspace/CivilPlan-MCP-v2/README.md`
- [ ] **Step 2: Rewrite README.md**
Full rewrite of `README.md` with these sections:
```markdown
# CivilPlan MCP v2.0
> AI-powered construction project planning for Korean civil engineering and building projects.
> Connects to Claude, ChatGPT, and other AI agents via the Model Context Protocol (MCP).
건설/건축 공사 사업계획을 AI와 함께 만듭니다.
MCP(Model Context Protocol)를 통해 Claude, ChatGPT 등 AI 에이전트와 연결됩니다.
---
## What's New in v2.0
| Feature | Description |
|---------|-------------|
| **3D Bird's-Eye View** | Generate photorealistic aerial and perspective renderings using Nano Banana Pro (Gemini 3 Pro Image) |
| **Perspective Rendering** | Eye-level visualizations for project presentations |
| **SVG Reference Input** | Use existing drawings as reference for more accurate renderings |
| **20 MCP Tools** | Now includes rendering alongside planning, estimation, and documentation |
---
## Features
### Planning & Analysis (11 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_parse_project` | Parse natural language project descriptions into structured data |
| `civilplan_get_legal_procedures` | Identify required permits, approvals, and legal procedures |
| `civilplan_get_phase_checklist` | Get phase-by-phase project checklists |
| `civilplan_evaluate_impact_assessments` | Evaluate 9 environmental/feasibility assessments |
| `civilplan_estimate_quantities` | Calculate material volumes from standard cross-sections |
| `civilplan_get_unit_prices` | Retrieve regional unit cost data (조달청 기준) |
| `civilplan_get_applicable_guidelines` | Find applicable design guidelines |
| `civilplan_fetch_guideline_summary` | Fetch guideline details |
| `civilplan_select_bid_type` | Recommend bidding methodology |
| `civilplan_estimate_waste_disposal` | Calculate construction waste estimates |
| `civilplan_query_land_info` | Query land/cadastral information (V-World API) |
### Financial Analysis (2 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_analyze_feasibility` | IRR, NPV, payback period, DSCR analysis |
| `civilplan_validate_against_benchmark` | Validate costs against government benchmarks |
### Document Generation (7 Tools)
| Tool | Description |
|------|-------------|
| `civilplan_generate_boq_excel` | Excel BOQ with cost breakdown (6 sheets) |
| `civilplan_generate_investment_doc` | Word investment plan document |
| `civilplan_generate_budget_report` | Budget report document |
| `civilplan_generate_schedule` | Gantt-style project timeline (Excel) |
| `civilplan_generate_svg_drawing` | SVG conceptual drawings |
| `civilplan_generate_dxf_drawing` | DXF CAD drawings |
| `civilplan_generate_birdseye_view` | **NEW** 3D aerial + perspective renderings |
### Supported Domains
| Domain | Korean | Status |
|--------|--------|--------|
| Roads | 토목_도로 | Full support |
| Buildings | 건축 | Full support |
| Water/Sewerage | 토목_상하수도 | Full support |
| Rivers | 토목_하천 | Full support |
| Landscaping | 조경 | Partial (in progress) |
| Mixed | 복합 | Full support |
---
## Quick Start
### 1. Clone & Install
```bash
git clone https://github.com/sinmb79/Construction-project-master.git
cd Construction-project-master
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install -r requirements.txt
```
### 2. Configure API Keys
Copy the example environment file:
```bash
cp .env.example .env
```
Edit `.env` and add your keys:
```env
# Required for 3D rendering (Nano Banana Pro)
GEMINI_API_KEY=your_gemini_api_key_here
# Optional - for land information queries
DATA_GO_KR_API_KEY=your_data_go_kr_key
VWORLD_API_KEY=your_vworld_key
```
**How to get a Gemini API key:**
1. Go to [Google AI Studio](https://aistudio.google.com/)
2. Click "Get API key" in the left sidebar
3. Create a new API key or use an existing project
4. Copy the key to your `.env` file
**Alternative: Encrypted local storage (Windows)**
```bash
python setup_keys.py
```
### 3. Start the Server
```bash
python server.py
```
Server runs at: `http://127.0.0.1:8765/mcp`
---
## Connecting to AI Agents
### Claude Desktop
Add to your Claude Desktop config file:
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
"mcpServers": {
"civilplan": {
"url": "http://127.0.0.1:8765/mcp"
}
}
}
```
Restart Claude Desktop after editing.
### Claude Code (CLI)
```bash
claude mcp add civilplan http://127.0.0.1:8765/mcp
```
### ChatGPT / OpenAI
ChatGPT supports MCP servers via compatible plugins or custom GPT configurations.
Connect using the server URL: `http://127.0.0.1:8765/mcp`
### Other MCP Clients
Any MCP-compatible client can connect using:
- **Transport:** Streamable HTTP
- **URL:** `http://127.0.0.1:8765/mcp`
---
## Example: Full Project Workflow
Input to AI agent:
> "경기도 화성시 지방도 890m, 폭 6m, 2차선 아스팔트 도로, 상하수도 포함, 2026~2028년 시행"
The AI agent can then use CivilPlan tools to:
1. **Parse** → Structured project data (domain, dimensions, region, utilities)
2. **Legal procedures** → 18 required permits, 18-month approval timeline
3. **Impact assessment** → 9 assessment categories evaluated
4. **Quantity estimation** → Material volumes (토공, 포장, 배수, 상하수도)
5. **Unit pricing** → Regional cost data applied (경기도 factor: 1.05)
6. **BOQ generation** → Excel with 6 sheets, total ~10.67 billion KRW
7. **Investment document** → Word document for approval process
8. **Schedule** → Gantt timeline with phase durations
9. **Drawings** → SVG/DXF conceptual drawings
10. **3D Rendering** → Bird's-eye view + perspective visualization
---
## 3D Rendering (Bird's-Eye View)
The `civilplan_generate_birdseye_view` tool generates two images per call:
| Output | Description |
|--------|-------------|
| Bird's-eye view | Aerial 45-60° angle showing full project extent |
| Perspective view | Eye-level view from the most representative viewpoint |
**Input options:**
- Text-only: Uses project description and technical details
- Text + SVG: Uses existing SVG drawing as spatial reference for more accurate results
**Resolution:** 2K (default) or 4K
**Powered by:** Google Nano Banana Pro (Gemini 3 Pro Image) via the `google-genai` SDK
---
## Architecture
```
MCP Client (Claude / ChatGPT / any MCP client)
│ MCP Protocol (Streamable HTTP)
┌─────────────────────────────────────┐
│ CivilPlan MCP Server │
│ (FastMCP 2.0+) │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 20 Tools │ │ Data Layer │ │
│ │ (parse, │ │ (JSON, SQL, │ │
│ │ legal, │ │ CSV) │ │
│ │ render) │ │ │ │
│ └────┬─────┘ └──────────────┘ │
│ │ │
│ ┌────▼─────────────────────┐ │
│ │ Services │ │
│ │ ├── Gemini Image API │ │
│ │ ├── V-World API │ │
│ │ └── Public Data Portal │ │
│ └───────────────────────────┘ │
└─────────────────────────────────────┘
```
---
## Data Sources
| Data | Source | Update Frequency |
|------|--------|-----------------|
| Unit prices | 조달청 표준시장단가 | Bi-annual (Jan, Jul) |
| Wages | 건설업 임금실태조사 | Bi-annual (Jan, Sep) |
| Legal procedures | 법제처 법령정보 | Manual updates |
| Region factors | 통계청 | Annual |
| Land prices | User-supplied CSV | As needed |
The server includes an automatic update scheduler (APScheduler) that checks for new data releases.
---
## Limitations
| Item | Detail |
|------|--------|
| Accuracy | ±20-30% (planning stage estimates only) |
| Scope | Conceptual planning — not for detailed engineering design |
| Landscaping | Legal procedure data incomplete |
| Land data | Requires manual CSV download to `data/land_prices/` |
| Rendering | Requires internet + Gemini API key |
| Platform | Encrypted key storage (DPAPI) is Windows-only; use `.env` on other platforms |
---
## Disclaimer
> **참고용 개략 자료 — 공식 제출 불가**
>
> All outputs are approximate reference materials for planning purposes only.
> They cannot replace professional engineering design review and are not valid for official submission.
> Verify all results with domain experts before use in actual projects.
---
## License
MIT License. Free to use, modify, and distribute.
## Author
22B Labs — Hongik Ingan: reducing inequality in access to expert planning knowledge.
```
- [ ] **Step 3: Commit**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git add README.md
git commit -m "docs: rewrite README for v2.0.0 with rendering guide and connection instructions"
```
---
### Task 7: Final Validation and Release
**Files:**
- No new files
- [ ] **Step 1: Run full test suite**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && python -m pytest tests/ -v`
Expected: All tests PASS
- [ ] **Step 2: Verify server starts without errors**
Run: `cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2 && timeout 5 python server.py 2>&1 || true`
Expected: Server starts, shows warnings for missing API keys, no import errors
- [ ] **Step 3: Tag release**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git tag -a v2.0.0 -m "v2.0.0: Add 3D bird's-eye view rendering with Nano Banana Pro"
```
- [ ] **Step 4: Push to remote with tags**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
git push origin main --tags
```
- [ ] **Step 5: Create GitHub release**
```bash
cd C:/Users/sinmb/workspace/CivilPlan-MCP-v2
gh release create v2.0.0 --title "CivilPlan MCP v2.0.0 — 3D Bird's-Eye View Rendering" --notes "$(cat <<'NOTES'
## What's New
### 3D Bird's-Eye View Rendering
- New `civilplan_generate_birdseye_view` MCP tool
- Powered by Google Nano Banana Pro (Gemini 3 Pro Image)
- Generates aerial (bird's-eye) + eye-level (perspective) renderings
- Supports text-only or text + SVG reference image input
- 2K / 4K resolution options
- Domain-specific prompt templates for road, building, water, river, landscape, and mixed projects
### Infrastructure
- New Gemini API integration via `google-genai` SDK
- Added `GEMINI_API_KEY` to configuration system
- Now 20 MCP tools total
### Documentation
- Complete README rewrite with:
- Tool reference tables
- Claude Desktop / Claude Code / ChatGPT connection guides
- API key setup instructions
- Full workflow example
- Architecture diagram
## Installation
```bash
git clone https://github.com/sinmb79/Construction-project-master.git
cd Construction-project-master
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# Add your GEMINI_API_KEY to .env
python server.py
```
## Breaking Changes
None. All existing 19 tools remain unchanged.
NOTES
)"
```
Expected: Release created on GitHub