"""M9 第一个用例:`capture_photo()` —— 粗搜机位 → 细出图。 ## 这个用例验证什么 plan §5 的原始动机(用户场景): > 「我和某个景物的合照」—— agent 先用**粗渲染**找好角度, > 再用**细渲染**出真实照片。 它之所以排在最后,是因为它**同时验证两个子系统**: - 粗渲染(M6)能不能给出**可用的机位候选**(不是拍脑袋的坐标) - 细渲染(M8)能不能把**服务端真状态**渲成图 ## 关键设计:机位从哪来 **不是**让 agent 拍脑袋报坐标,而是: 1. 先问 M6:目标可见吗?周围能站吗?地面在哪? 2. 由这些**观察**生成若干机位候选(正面/侧面/俯视/远景…) 3. 逐个(或挑最优的)用细渲染出图 4. 把「机位是怎么来的」一并返回 —— agent 能判断可不可信 ## 为什么不追求「自动选最优机位」 构图好坏是**审美判断**,属决策。按 mcfold 纪律(工具不决策), 本模块给出**结构化的机位候选 + 每个机位的观察依据**, 让 agent 自己选(可以 `shot_index` 指定,或全都要)。 """ from __future__ import annotations import math import os from typing import Any from . import rough from .state import WorldState def _facing_yaw(tx: float, tz: float, fx: float, fz: float) -> float: """从机位看向目标所需的 yaw(MC 约定:0=+Z)。""" dx, dz = tx - fx, tz - fz return (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0 def propose_shots( world: WorldState, target: tuple[float, float, float], *, radius: int = 3, max_shots: int = 6, ) -> dict[str, Any]: """用**粗渲染的观察**生成机位候选(不渲染,只观察)。 每个候选都带**为什么选它**(依据),便于 agent 判断与追问。 """ p = world.player tx, ty, tz = target ground = world.block_at(int(tx), int(ty) - 1, int(tz))["name"] base_y = ty - 0.6 # 略微下蹲的机位高度,比站在格心自然 cands: list[dict[str, Any]] = [] def add(name: str, fx: float, fy: float, fz: float, pitch: float, why: str, dist: float | None = None) -> None: d = dist if dist is not None else math.dist((fx, fy, fz), (tx, ty, tz)) cands.append({ "index": len(cands), "name": name, "camera": [round(fx, 2), round(fy, 2), round(fz, 2), round(_facing_yaw(tx, tz, fx, fz), 1), round(pitch, 1)], "distance": round(d, 2), "why": why, }) def add_at(name: str, fx: float, fy: float, fz: float, why: str, dist: float | None = None) -> None: """**由位置反算** yaw/pitch,保证真的看向目标。 ★ 为什么需要它:俯视机位在目标**正上方 4 格**,此时 水平方向的 yaw 已经退化(dx/dz ≈ 0,方位无意义), 真正的方向是「几乎垂直向下」。 我先前写死 `pitch=-35` 配水平算出的 yaw ⇒ 两者**不自洽** (实测 dot=0.82,相机没真正对着目标)。 ⇒ 只要机位不在目标同一水平面,就必须由向量反算朝向。 """ dx, dy, dz = tx - fx, ty - fy, tz - fz horiz = math.hypot(dx, dz) yaw = (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0 pitch = -math.degrees(math.atan2(dy, horiz)) if (horiz or dy) else 0.0 d = dist if dist is not None else math.sqrt(dx * dx + dy * dy + dz * dz) cands.append({ "index": len(cands), "name": name, "camera": [round(fx, 2), round(fy, 2), round(fz, 2), round(yaw, 1), round(pitch, 1)], "distance": round(d, 2), "why": why, }) # ① 目标正前方一格(经典正面照) dx, dz = tx - p.x, tz - p.z n = math.hypot(dx, dz) or 1.0 ux, uz = dx / n, dz / n fx, fz = tx - ux * 3.0, tz - uz * 3.0 fx, fz = int(round(fx)), int(round(fz)) if world.world.registry is not None and world.block_at(fx, int(ty) - 1, fz)["name"]: add_at("正面", fx, base_y, fz, f"站在目标正前方 3 格({fx},{int(ty)-1},{fz} 有 {ground} 支撑)") else: add_at("正面(悬空)", fx, base_y, fz, "目标正前方 3 格(脚下无支撑,可能悬空)") # ② 侧面 45° sx, sz = int(round(tx - uz * 3.0 + ux * 3.0)), int(round(tz + ux * 3.0 + uz * 3.0)) if world.block_at(sx, int(ty) - 1, sz)["name"]: add_at("斜侧", sx, base_y, sz, f"侧 45°、距离 3 格({sx},{int(ty)-1},{sz} 有支撑)") else: add_at("斜侧(悬空)", sx, base_y, sz, "侧 45°(脚下无支撑)") # ③ 俯视(看全景)—— 用 add_at:机位在目标上方,水平朝向退化 add_at("俯视", tx, ty + 4.0, tz + 0.01, "目标正上方 4 格俯视(朝向由位置反算,保证真的对着目标)") # ④ 远景(若视线通) far_x, far_z = int(round(tx - ux * 8.0)), int(round(tz - uz * 8.0)) vis = rough.can_see(world, (fx, base_y, fz)) add_at("远景", far_x, base_y, far_z, f"8 格外远景(近处视线:{vis['reason']})") # ⑤ 玩家当前机位(零成本:就在这儿) add("当前位置", p.x, p.y + 1.62, p.z, p.pitch, f"就用你现在的视角({p.yaw:.0f}°/{p.pitch:.0f}°)") return { "ok": True, "target": list(target), "shots": cands[:max_shots], "count": min(len(cands), max_shots), "target_ground": ground, "source": "粗渲染观察(M6 raycast/can_see/block_at),未出图", "note": "机位是从**观察**推出的候选,不是构图判断;" "用 shot_index 选一个,或对每个都出图再挑", } def capture_photo( world: WorldState, target: tuple[float, float, float] | None = None, *, store: str, shot_index: int | None = None, out_dir: str | None = None, name: str = "photo", hide_gui: bool = True, frames: int = 40, render_distance: int = 6, ) -> dict[str, Any]: """【M9】拍照:粗搜机位 → 细出图。 ## 参数 - `target` 目标坐标;省略则以**玩家朝向的前方**为目标 - `shot_index` 选第几个机位;省略则**每个机位都出一张** - `store` 内容根(含 `.mcfold` 渲染产物) ## 返回 ```python {"ok": True, "shots": [ {...,"image": "...", "ok": True}, ... ], "shot_options": [...], "target": [...]} ``` 出图失败的机位**不丢弃**(带上 `ok:false, why`), 这样 agent 能知道「哪些角度失败、为什么」。 """ from .fine import render_from_state # 延迟导入 p = world.player if target is None: # 用当前朝向的前方当目标(粗渲染的 raycast 就是这么算的) v = rough._look_vector(p.yaw, p.pitch) d = 6.0 target = (p.x + v[0] * d, p.y + 1.62 + v[1] * d, p.z + v[2] * d) prop = propose_shots(world, target) if not prop["shots"]: return {"ok": False, "why": "没能提出任何机位候选", "how_to": "确认状态层有区块(pump 几秒)", "target": list(target)} out_dir = out_dir or os.path.join( os.environ.get("MCFOLD_RUNTIME", "/tmp"), "photos") os.makedirs(out_dir, exist_ok=True) idxs = [shot_index] if shot_index is not None else \ [s["index"] for s in prop["shots"]] results: list[dict[str, Any]] = [] for i in idxs: if not (0 <= i < len(prop["shots"])): return {"ok": False, "why": f"shot_index {i} 越界(共 {len(prop['shots'])} 个机位)", "how_to": f"可用 index:0..{len(prop['shots'])-1}", "shot_options": prop["shots"]} s = prop["shots"][i] out = os.path.join(out_dir, f"{name}_{i}.png") r = render_from_state( world, store=store, camera=tuple(s["camera"]), out=out, hide_gui=hide_gui, frames=frames, render_distance=render_distance) results.append({**s, **r}) ok_any = any(r.get("ok") for r in results) return { "ok": ok_any, "target": list(target), "shots": results, "shot_options": prop["shots"], "target_ground": prop["target_ground"], "out_dir": out_dir, "note": (prop["note"] if ok_any else "所有机位都出图失败;见每个 shot 的 why"), }