feat(web): 海选按场景分组 + 删场景点位页签 + 演出真实底图 + 破缓存

- 海选审核左侧改两列:场景列(按新字段 ir.scene 手动归类聚合,含全部/未分类) + 该场景事件列
- 删独立「场景/点位」页签(pointview.js 保留未引用)
- 演出配置 Timeline 接真实场景俯视底图(setupShot 覆盖投影范围 + drawStage 叠图,复用 /api/pointsets 的 shot)
- 事件 meta 加「所属场景」归类输入框(datalist 提示已有场景名)
- db: events 加 scene 列 + 旧库 ALTER 迁移;upsert 镜像 ir.scene;list 返回
- app.py: 首页按文件 mtime 给 js/css 注入 ?v= 破浏览器缓存(根治新html配旧缓存js崩溃→弹口令)
This commit is contained in:
2026-06-15 11:46:59 +08:00
parent 603f78b77f
commit 65424a4dfb
7 changed files with 137 additions and 71 deletions

View File

@ -13,13 +13,14 @@ import datetime
import io import io
import json import json
import os import os
import re
import secrets import secrets
import sys import sys
import time import time
import zipfile import zipfile
from fastapi import FastAPI, Request, Response from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
@ -339,9 +340,26 @@ async def export_zip():
# ---------- 静态前端 ---------- # ---------- 静态前端 ----------
def _stamp_static_refs(html):
"""给 index.html 里引用的本地 js/css 加 ?v=<文件mtime> 破浏览器缓存。
只给改过的文件 bump 版本号mtime 变才变)→ 改前端不必手动改版本、也不会再出现
新 html 配旧缓存 js 的崩溃。外链(//)与已带 ?查询串的引用跳过。"""
def repl(m):
attr, ref = m.group(1), m.group(2)
if ref.startswith("http") or ref.startswith("//"):
return m.group(0)
fp = os.path.join(_STATIC_DIR, ref)
if os.path.isfile(fp):
return '%s="%s?v=%d"' % (attr, ref, int(os.path.getmtime(fp)))
return m.group(0)
return re.sub(r'(src|href)="([^"?]+\.(?:js|css))"', repl, html)
@app.get("/") @app.get("/")
async def index(): async def index():
return FileResponse(os.path.join(_STATIC_DIR, "index.html")) with open(os.path.join(_STATIC_DIR, "index.html"), encoding="utf-8") as f:
html = f.read()
return HTMLResponse(_stamp_static_refs(html))
app.mount("/", StaticFiles(directory=_STATIC_DIR), name="static") app.mount("/", StaticFiles(directory=_STATIC_DIR), name="static")

View File

@ -32,6 +32,7 @@ def init_db(path=None):
"group" TEXT PRIMARY KEY, "group" TEXT PRIMARY KEY,
title TEXT, title TEXT,
theme TEXT, theme TEXT,
scene TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
ir_json TEXT NOT NULL, ir_json TEXT NOT NULL,
updated_at TEXT, updated_at TEXT,
@ -39,6 +40,11 @@ def init_db(path=None):
notes TEXT notes TEXT
)""" )"""
) )
# 旧库迁移:补 scene 列(海选审核按场景分组的归类维度,镜像自 ir.scene
try:
c.execute("ALTER TABLE events ADD COLUMN scene TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass # 已存在
c.execute( c.execute(
"""CREATE TABLE IF NOT EXISTS sessions ( """CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
@ -80,7 +86,7 @@ def purge_sessions(now, path=None):
def list_events(status=None, path=None): def list_events(status=None, path=None):
"""列表(不含 ir_json轻量""" """列表(不含 ir_json轻量"""
sql = ('SELECT "group", title, theme, status, updated_at, updated_by, notes ' sql = ('SELECT "group", title, theme, scene, status, updated_at, updated_by, notes '
"FROM events") "FROM events")
args = [] args = []
if status and status != "all": if status and status != "all":
@ -106,21 +112,22 @@ def upsert_event(ir, by, now, notes=None, keep_status=True, path=None):
group = ir["id"] group = ir["id"]
title = ir.get("title", "") title = ir.get("title", "")
theme = ir.get("theme", "") theme = ir.get("theme", "")
scene = ir.get("scene", "") or "" # 海选审核分组维度,镜像自 ir.scene
ir_str = json.dumps(ir, ensure_ascii=False) ir_str = json.dumps(ir, ensure_ascii=False)
with _conn(path) as c: with _conn(path) as c:
exists = c.execute('SELECT status FROM events WHERE "group" = ?', exists = c.execute('SELECT status FROM events WHERE "group" = ?',
(group,)).fetchone() (group,)).fetchone()
if exists: if exists:
c.execute( c.execute(
'UPDATE events SET title=?, theme=?, ir_json=?, updated_at=?, ' 'UPDATE events SET title=?, theme=?, scene=?, ir_json=?, updated_at=?, '
'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?', 'updated_by=?, notes=COALESCE(?, notes) WHERE "group"=?',
(title, theme, ir_str, now, by, notes, group), (title, theme, scene, ir_str, now, by, notes, group),
) )
else: else:
c.execute( c.execute(
'INSERT INTO events ("group", title, theme, status, ir_json, ' 'INSERT INTO events ("group", title, theme, scene, status, ir_json, '
"updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?)", "updated_at, updated_by, notes) VALUES (?,?,?,?,?,?,?,?,?)",
(group, title, theme, "pending", ir_str, now, by, notes or ""), (group, title, theme, scene, "pending", ir_str, now, by, notes or ""),
) )
return group return group

View File

@ -9,7 +9,8 @@
status: null, status: null,
selectedNode: null, selectedNode: null,
dirty: false, dirty: false,
mode: "review", // review=海选审核 / perform=演出配置 / points=场景点位 mode: "review", // review=海选审核 / perform=演出配置
sceneCurrent: null, // 海选审核第一列选中的场景null=全部 / ""=未分类 / 具体场景名
by: localStorage.getItem("story_by") || "匿名", by: localStorage.getItem("story_by") || "匿名",
}; };
window.App = App; window.App = App;
@ -52,11 +53,36 @@
App.events = await r.json(); App.events = await r.json();
renderList(); renderList();
} }
function renderList() { function renderList() { renderSceneList(); renderEventList(); }
// 第一列:按 e.scene 聚合成场景,加「全部」「未分类」两个特殊项。
function renderSceneList() {
const host = $("scene-list"); host.innerHTML = "";
const counts = new Map(); let unclassified = 0;
App.events.forEach(e => {
const sc = e.scene || "";
if (!sc) { unclassified++; return; }
counts.set(sc, (counts.get(sc) || 0) + 1);
});
const mk = (key, label, cnt) => {
const d = document.createElement("div");
d.className = "scene-item" + (App.sceneCurrent === key ? " sel" : "");
d.innerHTML = '<span class="snm">' + esc(label) + '</span><span class="scnt">' + cnt + '</span>';
d.onclick = () => { App.sceneCurrent = key; renderList(); };
host.appendChild(d);
};
mk(null, "全部", App.events.length);
[...counts.keys()].sort().forEach(k => mk(k, k, counts.get(k)));
if (unclassified) mk("", "未分类", unclassified);
}
// 第二列:当前场景下、且匹配搜索的事件。
function renderEventList() {
const q = $("search").value.trim().toLowerCase(); const q = $("search").value.trim().toLowerCase();
const host = $("event-list"); host.innerHTML = ""; const host = $("event-list"); host.innerHTML = "";
const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] }; const badge = { pending: ["b-pending", "待审"], confirmed: ["b-confirmed", "已确认"], discarded: ["b-discarded", "已丢弃"] };
App.events App.events
.filter(e => App.sceneCurrent === null || (e.scene || "") === App.sceneCurrent)
.filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q)) .filter(e => !q || (e.title || "").toLowerCase().includes(q) || (e.group || "").toLowerCase().includes(q))
.forEach(e => { .forEach(e => {
const b = badge[e.status] || ["b-pending", e.status]; const b = badge[e.status] || ["b-pending", e.status];
@ -68,10 +94,10 @@
d.onclick = () => selectEvent(e.group); d.onclick = () => selectEvent(e.group);
host.appendChild(d); host.appendChild(d);
}); });
if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">无事件,点「导入 IR」</div>'; if (!host.children.length) host.innerHTML = '<div class="empty" style="padding:14px">该场景下无事件</div>';
} }
$("filter-status").onchange = loadList; $("filter-status").onchange = loadList;
$("search").oninput = renderList; $("search").oninput = renderEventList;
// ---------- 选中事件 ---------- // ---------- 选中事件 ----------
async function selectEvent(group) { async function selectEvent(group) {
@ -196,52 +222,22 @@
// ---------- 试走 ---------- // ---------- 试走 ----------
$("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict); $("btn-playtest").onclick = () => Playtest.open(App.ir, App.dict);
// ---------- 模式切换:海选审核 / 演出配置 / 场景点位 ---------- // ---------- 模式切换:海选审核 / 演出配置 ----------
function setMode(m) { function setMode(m) {
App.mode = m; App.mode = m;
$("mode-review").classList.toggle("active", m === "review"); $("mode-review").classList.toggle("active", m === "review");
$("mode-perform").classList.toggle("active", m === "perform"); $("mode-perform").classList.toggle("active", m === "perform");
$("mode-points").classList.toggle("active", m === "points");
$("wrap").classList.toggle("hidden", m !== "review"); $("wrap").classList.toggle("hidden", m !== "review");
$("perform-wrap").classList.toggle("hidden", m !== "perform"); $("perform-wrap").classList.toggle("hidden", m !== "perform");
$("points-wrap").classList.toggle("hidden", m !== "points");
$("review-toolbar").style.display = m === "review" ? "" : "none"; $("review-toolbar").style.display = m === "review" ? "" : "none";
document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调 document.body.classList.toggle("perform-mode", m === "perform"); // 切背景色调
document.body.classList.toggle("points-mode", m === "points");
Timeline.stop(); Timeline.stop();
if (m === "points") PointView.clear();
if (m === "perform") performLoadList(); if (m === "perform") performLoadList();
if (m === "points") pointsLoadList();
} }
$("mode-review").onclick = () => setMode("review"); $("mode-review").onclick = () => setMode("review");
$("mode-perform").onclick = () => setMode("perform"); $("mode-perform").onclick = () => setMode("perform");
$("mode-points").onclick = () => setMode("points");
// ---------- 场景点位页:点位集列表 + 只读白模查看器 ---------- // ---------- 演出配置页:已确认事件列表 + 内嵌预览 ----------
let pointsCurrent = null;
function pointsLoadList() {
const names = Object.keys(App.pointsets || {}).sort();
const host = $("points-set-list"); host.innerHTML = "";
if (!names.length) { host.innerHTML = '<div class="empty" style="padding:14px">未找到任何点位集StreamingAssets/Story/PointSets/*.points.json。</div>'; return; }
names.forEach(name => {
const ps = App.pointsets[name] || {};
const cnt = (ps.anchors || ps.points || []).length;
const d = document.createElement("div");
d.className = "ev" + (name === pointsCurrent ? " sel" : "");
d.innerHTML = '<div class="t">' + esc(name) + '</div><div class="g">'
+ (ps.mapId ? '地图 ' + esc(ps.mapId) + ' · ' : '') + cnt + ' 个点</div>';
d.onclick = () => pointsSelect(name);
host.appendChild(d);
});
}
function pointsSelect(name) {
pointsCurrent = name;
pointsLoadList();
$("points-empty").style.display = "none";
PointView.show($("points-view"), name, App.pointsets[name] || {});
}
// ---------- 演出配置页:已确认事件列表 + 内嵌白模预览 ----------
let performCurrent = null; let performCurrent = null;
async function performLoadList() { async function performLoadList() {
let list; let list;

View File

@ -21,6 +21,14 @@
} }
function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); } function field(label, input) { return el("div", { class: "fld" }, [el("label", {}, [label]), input]); }
function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; } function txt(val, oninput) { const i = el("input", { type: "text", value: val == null ? "" : val }); i.oninput = () => oninput(i.value); return i; }
// 带下拉建议的文本框datalist用于场景名等可复用又可自由输入的字段
function txtDatalist(val, listId, options, oninput) {
const i = el("input", { type: "text", value: val == null ? "" : val, list: listId, placeholder: "输入或选择场景名" });
i.oninput = () => oninput(i.value);
const dl = el("datalist", { id: listId });
(options || []).forEach(o => dl.appendChild(el("option", { value: o })));
return el("span", { class: "with-datalist" }, [i, dl]);
}
function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; } function area(val, oninput) { const t = el("textarea", {}, [val || ""]); t.oninput = () => oninput(t.value); return t; }
function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; } function num(val, oninput) { const i = el("input", { type: "number", value: val == null ? "" : val }); i.oninput = () => oninput(i.value === "" ? null : Number(i.value)); return i; }
function sel(val, opts, onchange) { function sel(val, opts, onchange) {
@ -107,6 +115,9 @@
const psHint = (ctx.pointNames && ctx.pointNames.length) const psHint = (ctx.pointNames && ctx.pointNames.length)
? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)"; ? ("点位集: " + ctx.pointNames.length + " 点") : "(无点位集,坐标校验降级警告)";
host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); }))); host.appendChild(field("标题", txt(ir.title, v => { ir.title = v; ctx.onChange(false); })));
// 所属场景:海选审核第一列的分组维度,手动归类(可复用已有场景名)。改后保存即生效。
const scenes = [...new Set((window.App && window.App.events ? window.App.events.map(e => e.scene) : []).filter(Boolean))].sort();
host.appendChild(field("所属场景(海选分组)", txtDatalist(ir.scene, "scene-datalist", scenes, v => { ir.scene = v; ctx.onChange(false); })));
host.appendChild(el("div", { class: "row2" }, [ host.appendChild(el("div", { class: "row2" }, [
field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })), field("主题", txt(ir.theme, v => { ir.theme = v; ctx.onChange(false); })),
field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })), field("规模", txt(ir.scale, v => { ir.scale = v; ctx.onChange(false); })),

View File

@ -25,7 +25,6 @@
<div class="mode-switch"> <div class="mode-switch">
<button id="mode-review" class="mode-btn active">海选审核</button> <button id="mode-review" class="mode-btn active">海选审核</button>
<button id="mode-perform" class="mode-btn">演出配置</button> <button id="mode-perform" class="mode-btn">演出配置</button>
<button id="mode-points" class="mode-btn">场景/点位</button>
</div> </div>
<div class="toolbar" id="review-toolbar"> <div class="toolbar" id="review-toolbar">
<button id="btn-save" class="primary" disabled>保存</button> <button id="btn-save" class="primary" disabled>保存</button>
@ -42,18 +41,26 @@
</header> </header>
<div id="wrap"> <div id="wrap">
<!-- 左:事件列表 --> <!-- 左:场景 / 事件 两列 -->
<aside id="list-pane"> <aside id="list-pane">
<div class="filters"> <!-- 第一列:场景 -->
<select id="filter-status"> <div id="scene-col">
<option value="all">全部</option> <div class="listhead">场景</div>
<option value="pending">待审</option> <div id="scene-list"></div>
<option value="confirmed">已确认</option> </div>
<option value="discarded">已丢弃</option> <!-- 第二列:该场景下的事件 -->
</select> <div id="event-col">
<input id="search" type="text" placeholder="搜索标题/group"> <div class="filters">
<select id="filter-status">
<option value="all">全部</option>
<option value="pending">待审</option>
<option value="confirmed">已确认</option>
<option value="discarded">已丢弃</option>
</select>
<input id="search" type="text" placeholder="搜索标题/group">
</div>
<div id="event-list"></div>
</div> </div>
<div id="event-list"></div>
</aside> </aside>
<!--分支图Drawflow 可拖拽连线) --> <!--分支图Drawflow 可拖拽连线) -->
@ -89,18 +96,6 @@
</main> </main>
</div> </div>
<!-- 场景/点位页(只读:看每个点位集里各点的真实位置/朝向,配 move.to 时对照用)-->
<div id="points-wrap" class="hidden">
<aside id="points-list-pane">
<div class="perform-listhead">场景点位集</div>
<div id="points-set-list"></div>
</aside>
<main id="points-main">
<div id="points-empty" class="empty-center">从左侧选择一个点位集,查看其中各点的位置与朝向</div>
<div id="points-view"></div>
</main>
</div>
<!-- 校验结果遮罩 --> <!-- 校验结果遮罩 -->
<div id="validate-modal" class="overlay hidden"> <div id="validate-modal" class="overlay hidden">
<div class="modal"> <div class="modal">
@ -149,7 +144,6 @@
<script src="playtest.js"></script> <script src="playtest.js"></script>
<script src="timeline.js"></script> <script src="timeline.js"></script>
<script src="scene_edit.js"></script> <script src="scene_edit.js"></script>
<script src="pointview.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -20,8 +20,25 @@ button.mini { padding:2px 8px; font-size:12px; }
/* ---- layout ---- */ /* ---- layout ---- */
#wrap { display:flex; flex:1; min-height:0; } #wrap { display:flex; flex:1; min-height:0; }
#list-pane { width:250px; background:#19150f; border-right:1px solid #3a322a; /* 海选审核左侧两列:场景列 | 事件列 */
display:flex; flex-direction:column; flex:none; } #list-pane { width:430px; background:#19150f; border-right:1px solid #3a322a;
display:flex; flex-direction:row; flex:none; }
#scene-col { width:150px; flex:none; display:flex; flex-direction:column;
border-right:1px solid #3a322a; background:#161009; }
#scene-col .listhead { padding:10px 12px; font-size:12px; color:#9a8f7e; letter-spacing:1px;
border-bottom:1px solid #3a322a; background:#19150f; }
#scene-list { overflow:auto; flex:1; }
.scene-item { padding:8px 10px; border-bottom:1px solid #241f18; cursor:pointer;
display:flex; justify-content:space-between; align-items:center; gap:6px;
font-size:13px; color:#cabfae; }
.scene-item:hover { background:#221d16; }
.scene-item.sel { background:#2a2316; border-left:3px solid #e6c878; padding-left:7px; color:#f0e2c0; }
.scene-item .snm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.scene-item .scnt { flex:none; font-size:11px; color:#7a7264; background:#241f18;
border-radius:9px; padding:0 7px; }
#event-col { flex:1; min-width:0; display:flex; flex-direction:column; }
.with-datalist { display:block; }
.with-datalist input { width:100%; box-sizing:border-box; }
.filters { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; } .filters { padding:10px; display:flex; gap:6px; border-bottom:1px solid #3a322a; }
.filters select, .filters input, #login input, #import-text { .filters select, .filters input, #login input, #import-text {
background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px; background:#241f18; color:#e8e0d4; border:1px solid #4a4030; border-radius:4px;

View File

@ -304,6 +304,7 @@
// ===================== 状态 & 挂载 ===================== // ===================== 状态 & 挂载 =====================
let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false, lastDecisionId = null; let S, model, playT = 0, playing = false, rafId = 0, lastTs = 0, stageCv, stageCtx, els = {}, pending = null, selNode = null, PX = 80, fitMode = false, lastDecisionId = null;
let shotBg = null, shotReady = false, shotBounds = null; // 真实场景正交俯视底图
const TEMPLATE = const TEMPLATE =
'<div class="tl-stagepanel">' + '<div class="tl-stagepanel">' +
@ -326,6 +327,7 @@
const psName = (IR.stage || {}).point_set || IR.id; const psName = (IR.stage || {}).point_set || IR.id;
const ps = (POINTSETS || {})[psName] || {}; const ps = (POINTSETS || {})[psName] || {};
S = prepare(IR, ps.anchors || []); model = S; S = prepare(IR, ps.anchors || []); model = S;
setupShot(ps.shot); // 有真实场景俯视底图 → 覆盖投影范围并异步加载drawStage 当舞台底
host.innerHTML = TEMPLATE; host.innerHTML = TEMPLATE;
els = { els = {
@ -344,7 +346,8 @@
playhead: null, playhead: null,
}; };
els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") + els.mapinfo.textContent = "点位集:" + psName + (ps.mapId ? "(地图 " + ps.mapId + "" : "") +
(S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)" : " · 真实坐标"); (S.synthetic ? " · ⚠ 未取到真实坐标,按示意布局自动铺开(走位仍可预览)"
: (shotBounds ? " · 真实坐标 · 已叠场景俯视底图" : " · 真实坐标"));
stageCv = els.stage; stageCtx = stageCv.getContext("2d"); stageCv = els.stage; stageCtx = stageCv.getContext("2d");
els.play.onclick = () => playing ? stopPlay() : play(); els.play.onclick = () => playing ? stopPlay() : play();
@ -405,6 +408,20 @@
} }
function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = S = null; } function clear() { stopPlay(); if (els.host) els.host.innerHTML = ""; els = {}; model = S = null; }
// 真实场景俯视底图shot={url,bounds:[minX,maxX,minZ,maxZ]}Unity 抓拍产出)。
// 有底图时把投影范围覆盖成底图覆盖的 map-local 范围 → actor/锚点像素级落在真实场景上。
function setupShot(shot) {
shotBg = null; shotReady = false; shotBounds = null;
if (!shot || !Array.isArray(shot.bounds) || shot.bounds.length !== 4) return;
const b = shot.bounds;
shotBounds = { minX: b[0], maxX: b[1], minZ: b[2], maxZ: b[3] };
S.bounds = shotBounds; // 与底图对齐(口径同 pointview屏幕右=+X、上=+Z
shotBg = new Image();
shotBg.onload = () => { shotReady = true; if (stageCv) renderFrame(); };
shotBg.onerror = () => { shotReady = false; };
shotBg.src = shot.url;
}
function restart() { startFrom(firstNode(S.IR)); } function restart() { startFrom(firstNode(S.IR)); }
function refreshTimeline() { orderRows(S); buildTracks(); } function refreshTimeline() { orderRows(S); buildTracks(); }
@ -472,6 +489,12 @@
function drawStage(tau) { function drawStage(tau) {
const ctx = stageCtx, w = stageCv.width, h = stageCv.height; const ctx = stageCtx, w = stageCv.width, h = stageCv.height;
ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h); ctx.clearRect(0, 0, w, h); ctx.fillStyle = "#15130d"; ctx.fillRect(0, 0, w, h);
// 真实场景俯视底图:画进 bounds 投影出的矩形(纵横比=bounds=图,故像素级贴合)
if (shotBg && shotReady && shotBounds) {
const tl = worldToStage({ x: shotBounds.minX, z: shotBounds.maxZ });
const br = worldToStage({ x: shotBounds.maxX, z: shotBounds.minZ });
ctx.drawImage(shotBg, tl.x, tl.y, br.x - tl.x, br.y - tl.y);
}
(model.anchors || []).forEach(a => { (model.anchors || []).forEach(a => {
const p = worldToStage({ x: a.pos[0], z: a.pos[2] }); const p = worldToStage({ x: a.pos[0], z: a.pos[2] });
ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = "rgba(180,170,140,.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.stroke();