Files
ChildHood/tools/world-sketcher/sketch.html
T
starmen1265 bb34641bfe feat(world-sketcher): 增加高度标注和连通关系,AI分析改为读JSON
- sketch.html: 双击命名弹窗增加高度(m)输入和连通多选
- sketch.html: 新增defaultHeightForType()按画笔类型设默认高度
- sketch.html: 连通关系双向同步,JSON输出增加height_m/connections
- sketch.html: restoreScene恢复新字段,保存/加载流程不受影响
- SKILL.md: 步骤3从vision分析PNG改为读取JSON结构化数据
- SKILL.md: 布局解读文档模板增加高度和连通列
- README.md: 画笔图例增加默认高度,更新JSON示例和AI流程说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:06:05 +08:00

1087 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChildHood — World Layout Sketcher</title>
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--sidebar-w: 264px;
--toolbar-h: 40px;
--statusbar-h: 28px;
--bg-dark: #2d2d2d;
--bg-light: #e8e8e8;
--bg-canvas: #f0f0f0;
--text-light: #ddd;
--text-dim: #999;
--accent: #4a9eff;
--border: #444;
}
html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 13px; background: var(--bg-dark); color: var(--text-light); }
#app { display: flex; height: 100%; }
#sidebar { width: var(--sidebar-w); min-width: var(--sidebar-w); background: var(--bg-dark); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-section { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sidebar-section h3 { font-size: 11px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; letter-spacing: 0.5px; }
#breadcrumb { display: flex; align-items: center; gap: 4px; font-size: 12px; flex-wrap: wrap; }
#breadcrumb span { cursor: pointer; color: var(--accent); }
#breadcrumb span:hover { text-decoration: underline; }
#breadcrumb .sep { color: var(--text-dim); cursor: default; }
#breadcrumb .current { color: var(--text-light); cursor: default; }
#breadcrumb .current:hover { text-decoration: none; }
#scene-name-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
#scene-name-row label { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
#scene-name-input { flex: 1; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 3px 6px; border-radius: 3px; font-size: 12px; }
.world-size-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.world-size-row label { font-size: 11px; color: var(--text-dim); min-width: 32px; }
.world-size-row input { width: 60px; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 3px 6px; border-radius: 3px; text-align: right; font-size: 12px; }
.world-size-row .unit { font-size: 11px; color: var(--text-dim); }
#scale-display { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
#brush-palette { display: flex; flex-direction: column; gap: 3px; }
.brush-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 4px; cursor: pointer; border: 2px solid transparent; transition: background 0.15s; }
.brush-item:hover { background: #3a3a3a; }
.brush-item.selected { border-color: #fff; background: #3a3a3a; }
.brush-swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #666; flex-shrink: 0; }
.brush-label { font-size: 12px; }
#brush-size-slider { width: 100%; accent-color: var(--accent); }
#brush-size-val { font-size: 11px; color: var(--text-dim); text-align: right; }
#grid-toggle-row { display: flex; align-items: center; gap: 8px; }
#grid-toggle-row input { accent-color: var(--accent); }
#grid-toggle-row label { cursor: pointer; font-size: 12px; }
#elements-list { max-height: 200px; overflow-y: auto; }
#elements-list:empty::before { content: "双击画布上的色块来命名"; color: var(--text-dim); font-size: 11px; font-style: italic; }
.el-item { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 3px; cursor: pointer; font-size: 12px; }
.el-item:hover { background: #3a3a3a; }
.el-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
.el-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.el-enter { font-size: 10px; color: var(--accent); cursor: pointer; }
.el-enter:hover { text-decoration: underline; }
#workspace { flex: 1; display: flex; flex-direction: column; background: var(--bg-light); }
#toolbar { height: var(--toolbar-h); background: #383838; display: flex; align-items: center; padding: 0 8px; gap: 4px; border-bottom: 1px solid var(--border); }
#toolbar .tool-group { display: flex; gap: 2px; padding: 0 6px; border-right: 1px solid #555; }
#toolbar .tool-group:last-child { border-right: none; }
.tbtn { background: #4a4a4a; color: #ddd; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; line-height: 1; white-space: nowrap; }
.tbtn:hover { background: #5a5a5a; }
.tbtn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
.tbtn-icon { font-size: 14px; padding: 4px 7px; }
#canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; position: relative; background: #ccc; }
#canvas-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,0.3); background: #f0f0f0; }
#canvas-wrap canvas { position: absolute; top: 0; left: 0; }
#l1-base-canvas { z-index: 0; }
#drawing-canvas { z-index: 1; cursor: crosshair; }
#grid-canvas { z-index: 2; pointer-events: none; }
#ui-canvas { z-index: 3; pointer-events: none; }
#statusbar { height: var(--statusbar-h); background: #383838; display: flex; align-items: center; padding: 0 12px; gap: 16px; font-size: 11px; color: var(--text-dim); border-top: 1px solid var(--border); }
#status-feedback { color: #4ae060; font-weight: bold; transition: opacity 0.3s; }
#name-modal { display: none; position: fixed; z-index: 100; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
#name-dialog { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--bg-dark); border: 1px solid #666; border-radius: 8px; padding: 16px; min-width: 280px; }
#name-dialog h4 { margin-bottom: 10px; font-size: 13px; }
#name-dialog input[type=text] { width: 100%; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 6px 8px; border-radius: 3px; margin-bottom: 8px; font-size: 13px; }
#name-dialog label { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 10px; }
#name-dialog label input { accent-color: var(--accent); }
#name-dialog .btn-row { display: flex; gap: 8px; justify-content: flex-end; }
#name-dialog button { padding: 5px 14px; border-radius: 3px; border: 1px solid #555; cursor: pointer; font-size: 12px; }
#name-dialog .btn-ok { background: var(--accent); color: #fff; border-color: var(--accent); }
#name-dialog .btn-cancel { background: #4a4a4a; color: #ddd; }
#name-height-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 12px; }
#name-height-row input { width: 60px; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 4px 6px; border-radius: 3px; text-align: right; font-size: 12px; }
#name-connections-row { margin-bottom: 8px; font-size: 12px; }
#name-connections-row > label { display: block; margin-bottom: 4px; color: var(--text-dim); }
#name-connections-list { max-height: 100px; overflow-y: auto; background: #3a3a3a; border-radius: 3px; padding: 4px; }
#name-connections-list label { display: flex; align-items: center; gap: 6px; padding: 2px 4px; cursor: pointer; border-radius: 2px; }
#name-connections-list label:hover { background: #4a4a4a; }
#name-connections-list input { accent-color: var(--accent); }
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<div class="sidebar-section">
<h3>导航 Navigation</h3>
<div id="breadcrumb"><span class="current">场景总览</span></div>
</div>
<div class="sidebar-section">
<h3>场景 Scene</h3>
<div id="scene-name-row">
<label>名称</label>
<input id="scene-name-input" type="text" value="新场景" placeholder="场景名称">
</div>
<div class="world-size-row">
<label>宽</label><input id="world-w" type="number" value="500" min="10" max="10000"> <span class="unit">m</span>
<label>高</label><input id="world-h" type="number" value="400" min="10" max="10000"> <span class="unit">m</span>
</div>
<div id="scale-display">1 px = 50 cm</div>
</div>
<div class="sidebar-section">
<h3>画笔 Brush</h3>
<div id="brush-palette"></div>
<div style="margin-top:8px;">
<label style="font-size:11px;color:var(--text-dim);">粗细 <span id="brush-size-val">8 px</span></label>
<input id="brush-size-slider" type="range" min="1" max="40" value="8">
</div>
</div>
<div class="sidebar-section">
<div id="grid-toggle-row">
<input type="checkbox" id="grid-toggle" checked>
<label for="grid-toggle">网格 Grid</label>
</div>
</div>
<div class="sidebar-section" style="flex:1;">
<h3>元素 Elements</h3>
<div id="elements-list"></div>
</div>
</aside>
<div id="workspace">
<div id="toolbar">
<div class="tool-group">
<button class="tbtn tbtn-icon active" data-tool="pen" title="画笔 (P)">&#9998;</button>
<button class="tbtn tbtn-icon" data-tool="eraser" title="橡皮擦 (E)">&#9746;</button>
</div>
<div class="tool-group">
<button class="tbtn tbtn-icon" id="btn-undo" title="撤销 (Ctrl+Z)">&#8630;</button>
<button class="tbtn tbtn-icon" id="btn-redo" title="重做 (Ctrl+Y)">&#8631;</button>
</div>
<div class="tool-group">
<button class="tbtn" id="btn-clear">清空</button>
</div>
<div class="tool-group">
<button class="tbtn" id="btn-save-all">保存 ZIP</button>
<button class="tbtn" id="btn-load">加载</button>
</div>
</div>
<div id="canvas-container">
<div id="canvas-wrap">
<canvas id="l1-base-canvas"></canvas>
<canvas id="drawing-canvas"></canvas>
<canvas id="grid-canvas"></canvas>
<canvas id="ui-canvas"></canvas>
</div>
</div>
<div id="statusbar">
<span id="status-pos">--</span>
<span id="status-level">L1 场景总览</span>
<span id="status-canvas">1000 × 800</span>
<span id="status-feedback"></span>
</div>
</div>
</div>
<div id="name-modal">
<div id="name-dialog">
<h4>命名元素</h4>
<input id="name-input" type="text" placeholder="输入名称(如"家"、"小卖部")">
<label><input type="checkbox" id="name-enterable"> 可进入(有下级详图)</label>
<div id="name-height-row" style="display:none;">
<label style="color:var(--text-dim);">高度</label>
<input id="name-height" type="number" min="0" step="0.5" value="3">
<span style="color:var(--text-dim);">m</span>
</div>
<div id="name-connections-row" style="display:none;">
<label>连通到</label>
<div id="name-connections-list"></div>
</div>
<div class="btn-row">
<button class="btn-cancel" id="name-cancel">取消</button>
<button class="btn-ok" id="name-ok">确定</button>
</div>
</div>
</div>
<input type="file" id="file-input" accept=".json,.zip" style="display:none">
<script>
"use strict";
/* ============================================================
画笔定义 — levels: L1 / L2 / both
============================================================ */
const BRUSHES = [
{ id: "road", label: "道路 Road", hex: "#909090", mi: "MI_DevGray", levels: "both" },
{ id: "water", label: "水域 Water", hex: "#40A0D0", mi: "MI_DevSkyBlue", levels: "both" },
{ id: "blocked", label: "不可通行 Blocked", hex: "#A07040", mi: "MI_DevBrown", levels: "both" },
{ id: "zone", label: "区域 Zone", hex: "#D04040", mi: "MI_DevRed", levels: "L1" },
{ id: "building", label: "建筑 Building", hex: "#E07020", mi: "MI_DevOrange", levels: "L2" },
{ id: "greenery", label: "植被 Greenery", hex: "#40B040", mi: "MI_DevGreen", levels: "L2" },
{ id: "eraser", label: "橡皮擦 Eraser", hex: null, levels: "both" },
];
const BRUSH_RGB = BRUSHES.filter(b => b.hex).map(b => ({
id: b.id, hex: b.hex, mi: b.mi, levels: b.levels,
r: parseInt(b.hex.substr(1, 2), 16),
g: parseInt(b.hex.substr(3, 2), 16),
b: parseInt(b.hex.substr(5, 2), 16),
}));
/* 每个层级允许扫描输出的画笔类型 */
const SCAN_TYPES = {
L1: ["road", "water", "blocked", "zone"],
L2: ["road", "water", "blocked", "building", "greenery"],
};
/* ============================================================
App
============================================================ */
const App = {
currentBrush: "road",
currentTool: "pen",
brushSize: 8,
gridVisible: true,
currentLevel: "L1",
sceneName: "新场景",
levelData: {},
undoStack: [],
redoStack: [],
maxUndo: 20,
isDrawing: false,
lastX: 0, lastY: 0,
lastClickTime: 0, lastClickX: 0, lastClickY: 0,
defaultL1: { canvasW: 1000, canvasH: 800 },
// DOM
dc: null, gc: null, uc: null, l1c: null, wrap: null, wW: null, wH: null,
/* ---- 初始化 ---- */
init() {
this.dc = document.getElementById("drawing-canvas");
this.gc = document.getElementById("grid-canvas");
this.uc = document.getElementById("ui-canvas");
this.l1c = document.getElementById("l1-base-canvas");
this.wrap = document.getElementById("canvas-wrap");
this.wW = document.getElementById("world-w");
this.wH = document.getElementById("world-h");
this.initToolbar();
this.initCanvasEvents();
this.initKeyboard();
this.initLevel("L1", 500, 400, this.defaultL1.canvasW, this.defaultL1.canvasH);
this.switchToLevel("L1");
},
initLevel(id, wW, wH, cW, cH) {
if (!this.levelData[id]) {
this.levelData[id] = {
worldW: wW, worldH: wH, canvasW: cW, canvasH: cH,
elements: [], imageData: null, l1BaseData: null,
parentLevel: null, parentElement: null,
};
}
},
/* ---- 层级切换 ---- */
switchToLevel(id) {
const d = this.levelData[id];
if (!d) return;
this.currentLevel = id;
this.setupCanvasSize(d.canvasW, d.canvasH);
// 绘图层:L1 填灰色,L2 填透明(让 l1-base-canvas 透出来)
const ctx = this.dc.getContext("2d");
if (d.imageData) {
ctx.putImageData(d.imageData, 0, 0);
} else {
if (id === "L1") {
ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, d.canvasW, d.canvasH);
} else {
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
}
// L1 底图层(L2 模式下显示 L1 缩放内容)
const l1ctx = this.l1c.getContext("2d");
if (d.l1BaseData) {
l1ctx.putImageData(d.l1BaseData, 0, 0);
} else {
l1ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
this.wW.value = d.worldW;
this.wH.value = d.worldH;
this.undoStack = []; this.redoStack = [];
this.updateUndoButtons();
this.drawGrid();
this.renderBrushPalette();
this.updateBreadcrumb();
this.updateElementsList();
this.updateScaleDisplay();
this.updateStatus();
},
saveCurrentCanvas() {
const d = this.levelData[this.currentLevel];
if (d) d.imageData = this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH);
},
setupCanvasSize(w, h) {
[this.l1c, this.dc, this.gc, this.uc].forEach(c => { c.width = w; c.height = h; });
this.wrap.style.width = w + "px";
this.wrap.style.height = h + "px";
},
/* ---- 画笔面板(按层级动态过滤)---- */
renderBrushPalette() {
const palette = document.getElementById("brush-palette");
palette.innerHTML = "";
const isL1 = this.currentLevel === "L1";
const available = BRUSHES.filter(b => b.levels === "both" || b.levels === (isL1 ? "L1" : "L2"));
// 如果当前画笔不在可用列表中,切换到第一个
if (!available.find(b => b.id === this.currentBrush)) {
this.currentBrush = available[0].id;
}
available.forEach(b => {
const div = document.createElement("div");
div.className = "brush-item" + (b.id === this.currentBrush ? " selected" : "");
div.dataset.brush = b.id;
const sw = document.createElement("div");
sw.className = "brush-swatch";
sw.style.background = b.hex || "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/10px 10px";
const lb = document.createElement("span");
lb.className = "brush-label"; lb.textContent = b.label;
div.append(sw, lb);
div.addEventListener("click", () => { this.currentBrush = b.id; this.renderBrushPalette(); });
palette.appendChild(div);
});
this.dc.style.cursor = this.currentBrush === "eraser" ? "cell" : "crosshair";
},
/* ---- 工具栏 ---- */
initToolbar() {
document.querySelectorAll("[data-tool]").forEach(btn => {
btn.addEventListener("click", () => {
this.currentTool = btn.dataset.tool;
document.querySelectorAll("[data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === this.currentTool));
});
});
document.getElementById("btn-undo").addEventListener("click", () => this.undo());
document.getElementById("btn-redo").addEventListener("click", () => this.redo());
document.getElementById("btn-clear").addEventListener("click", () => this.clearCanvas());
document.getElementById("btn-save-all").addEventListener("click", () => this.saveAll());
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-input").click());
},
/* ---- Canvas 事件 ---- */
initCanvasEvents() {
const s = this;
this.dc.addEventListener("mousedown", e => s.onMouseDown(e));
this.dc.addEventListener("mousemove", e => s.onMouseMove(e));
this.dc.addEventListener("mouseup", e => s.onMouseUp(e));
this.dc.addEventListener("mouseleave", e => s.onMouseUp(e));
this.dc.addEventListener("contextmenu", e => e.preventDefault());
this.dc.addEventListener("touchstart", e => { e.preventDefault(); s.onMouseDown(s.touchToMouse(e)); });
this.dc.addEventListener("touchmove", e => { e.preventDefault(); s.onMouseMove(s.touchToMouse(e)); });
this.dc.addEventListener("touchend", e => { e.preventDefault(); s.onMouseUp(s.touchToMouse(e)); });
this.wW.addEventListener("change", () => s.onWorldSizeChange());
this.wH.addEventListener("change", () => s.onWorldSizeChange());
document.getElementById("brush-size-slider").addEventListener("input", function() {
s.brushSize = parseInt(this.value);
document.getElementById("brush-size-val").textContent = s.brushSize + " px";
});
document.getElementById("grid-toggle").addEventListener("change", function() {
s.gridVisible = this.checked; s.drawGrid();
});
document.getElementById("scene-name-input").addEventListener("input", function() {
s.sceneName = this.value || "新场景";
});
document.getElementById("file-input").addEventListener("change", e => s.onFileLoad(e));
},
touchToMouse(e) {
const t = e.touches[0] || e.changedTouches[0];
const r = this.dc.getBoundingClientRect();
return { offsetX: t.clientX - r.left, offsetY: t.clientY - r.top };
},
isEraser() { return this.currentBrush === "eraser" || this.currentTool === "eraser"; },
getBrushColor() { const b = BRUSHES.find(b => b.id === this.currentBrush); return b ? b.hex : null; },
onMouseDown(e) {
const p = { x: e.offsetX, y: e.offsetY };
const now = Date.now();
if (now - this.lastClickTime < 400 && Math.abs(p.x - this.lastClickX) < 10 && Math.abs(p.y - this.lastClickY) < 10) {
this.lastClickTime = 0; this.showNameModal(p.x, p.y); return;
}
this.lastClickTime = now; this.lastClickX = p.x; this.lastClickY = p.y;
this.pushUndo(); this.isDrawing = true; this.lastX = p.x; this.lastY = p.y;
this.drawDot(p.x, p.y);
},
onMouseMove(e) {
const p = { x: e.offsetX, y: e.offsetY };
this.updateStatusPos(p.x, p.y);
if (!this.isDrawing) return;
this.drawLine(this.lastX, this.lastY, p.x, p.y);
this.lastX = p.x; this.lastY = p.y;
},
onMouseUp() { this.isDrawing = false; },
drawDot(x, y) {
const ctx = this.dc.getContext("2d");
const erase = this.isEraser();
ctx.globalCompositeOperation = erase ? "destination-out" : "source-over";
ctx.fillStyle = erase ? "rgba(0,0,0,1)" : this.getBrushColor();
ctx.beginPath(); ctx.arc(x, y, this.brushSize / 2, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = "source-over";
},
drawLine(x1, y1, x2, y2) {
const ctx = this.dc.getContext("2d");
const erase = this.isEraser();
ctx.globalCompositeOperation = erase ? "destination-out" : "source-over";
ctx.strokeStyle = erase ? "rgba(0,0,0,1)" : this.getBrushColor();
ctx.lineWidth = this.brushSize; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.globalCompositeOperation = "source-over";
},
pushUndo() {
const d = this.levelData[this.currentLevel];
this.undoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
if (this.undoStack.length > this.maxUndo) this.undoStack.shift();
this.redoStack = []; this.updateUndoButtons();
},
undo() {
if (!this.undoStack.length) return;
const d = this.levelData[this.currentLevel];
this.redoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
this.dc.getContext("2d").putImageData(this.undoStack.pop(), 0, 0);
this.updateUndoButtons();
},
redo() {
if (!this.redoStack.length) return;
const d = this.levelData[this.currentLevel];
this.undoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
this.dc.getContext("2d").putImageData(this.redoStack.pop(), 0, 0);
this.updateUndoButtons();
},
updateUndoButtons() {
document.getElementById("btn-undo").disabled = !this.undoStack.length;
document.getElementById("btn-redo").disabled = !this.redoStack.length;
},
clearCanvas() {
if (!confirm("清空当前画布?不可撤销。")) return;
const d = this.levelData[this.currentLevel];
const ctx = this.dc.getContext("2d");
if (this.currentLevel === "L1") {
ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, d.canvasW, d.canvasH);
} else {
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
this.undoStack = []; this.redoStack = []; this.updateUndoButtons();
d.elements = []; this.updateElementsList();
},
/* ---- 网格 ---- */
drawGrid() {
const ctx = this.gc.getContext("2d"), d = this.levelData[this.currentLevel];
if (!d) return;
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
if (!this.gridVisible) return;
const s = d.worldW / d.canvasW;
let gs = 1 / s; if (gs < 4) gs *= 5; if (gs < 4) gs *= 5; if (gs < 4) gs *= 4;
const ms = gs * 5;
ctx.strokeStyle = "rgba(0,0,0,0.08)"; ctx.beginPath();
for (let x = gs; x < d.canvasW; x += gs) { ctx.moveTo(Math.round(x)+.5, 0); ctx.lineTo(Math.round(x)+.5, d.canvasH); }
for (let y = gs; y < d.canvasH; y += gs) { ctx.moveTo(0, Math.round(y)+.5); ctx.lineTo(d.canvasW, Math.round(y)+.5); }
ctx.stroke();
ctx.strokeStyle = "rgba(0,0,0,0.18)"; ctx.beginPath();
for (let x = ms; x < d.canvasW; x += ms) { ctx.moveTo(Math.round(x)+.5, 0); ctx.lineTo(Math.round(x)+.5, d.canvasH); }
for (let y = ms; y < d.canvasH; y += ms) { ctx.moveTo(0, Math.round(y)+.5); ctx.lineTo(d.canvasW, Math.round(y)+.5); }
ctx.stroke();
ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.font = "10px sans-serif";
ctx.textAlign = "left"; ctx.textBaseline = "top";
for (let x = ms; x < d.canvasW; x += ms) ctx.fillText((x*s).toFixed(0)+"m", x+2, 2);
ctx.textAlign = "right"; ctx.textBaseline = "bottom";
for (let y = ms; y < d.canvasH; y += ms) ctx.fillText(((d.canvasH-y)*s).toFixed(0)+"m", d.canvasW-2, y-2);
},
onWorldSizeChange() {
const d = this.levelData[this.currentLevel]; if (!d) return;
d.worldW = Math.max(10, parseInt(this.wW.value) || 500);
d.worldH = Math.max(10, parseInt(this.wH.value) || 400);
this.drawGrid(); this.updateScaleDisplay(); this.updateStatus();
},
updateScaleDisplay() {
const d = this.levelData[this.currentLevel]; if (!d) return;
const s = d.worldW / d.canvasW;
document.getElementById("scale-display").textContent = s >= 1 ? `1 px = ${s.toFixed(1)} m` : `1 px = ${(s*100).toFixed(0)} cm`;
},
updateStatusPos(px, py) {
const d = this.levelData[this.currentLevel]; if (!d) return;
const s = d.worldW / d.canvasW;
document.getElementById("status-pos").textContent =
`像素 (${px}, ${py}) 世界 (${(px*s).toFixed(1)}m, ${((d.canvasH-py)*s).toFixed(1)}m)`;
},
updateStatus() {
const d = this.levelData[this.currentLevel];
document.getElementById("status-level").textContent = this.currentLevel === "L1" ? "L1 场景总览" : `L2 ${this.getLevelDisplayName(this.currentLevel)}`;
document.getElementById("status-canvas").textContent = `${d.canvasW} × ${d.canvasH}`;
},
getLevelDisplayName(id) {
const d = this.levelData[id]; if (!d) return id;
const z = d.elements.find(e => e.type === "zone" && e.name);
return z ? z.name : id;
},
/* ---- 面包屑导航 ---- */
updateBreadcrumb() {
const bc = document.getElementById("breadcrumb"); bc.innerHTML = "";
if (this.currentLevel === "L1") {
bc.innerHTML = '<span class="current">场景总览</span>';
} else {
const l1 = document.createElement("span"); l1.textContent = "场景总览";
l1.addEventListener("click", () => this.navigateToLevel("L1"));
bc.appendChild(l1);
bc.insertAdjacentHTML("beforeend", '<span class="sep"> › </span>');
bc.insertAdjacentHTML("beforeend", `<span class="current">${this.getLevelDisplayName(this.currentLevel)}</span>`);
}
},
navigateToLevel(id) {
if (id === this.currentLevel) return;
if (this.currentLevel.startsWith("L2-") && id === "L1") this.compositeL2BackToL1();
this.saveCurrentCanvas();
this.switchToLevel(id);
},
/* ---- L1↔L2 双向同步 ---- */
findColorBBox(imageData, cw, ch, px, py, tol) {
const d = imageData.data;
const idx = (py * cw + px) * 4;
if (d[idx+3] < 50) return null;
let bm = null;
for (const b of BRUSH_RGB) {
if (Math.abs(d[idx]-b.r) < tol && Math.abs(d[idx+1]-b.g) < tol && Math.abs(d[idx+2]-b.b) < tol) { bm = b; break; }
}
if (!bm) return null;
const vis = new Uint8Array(cw * ch);
const q = [px + py * cw]; vis[q[0]] = 1;
let x0=px, x1=px, y0=py, y1=py;
while (q.length) {
const p = q.pop(), cx = p % cw, cy = (p - cx) / cw;
if (cx < x0) x0 = cx; if (cx > x1) x1 = cx;
if (cy < y0) y0 = cy; if (cy > y1) y1 = cy;
for (const np of [cx>0?p-1:-1, cx<cw-1?p+1:-1, cy>0?p-cw:-1, cy<ch-1?p+cw:-1]) {
if (np < 0 || vis[np]) continue;
const ni = np * 4;
if (Math.abs(d[ni]-bm.r)<tol && Math.abs(d[ni+1]-bm.g)<tol && Math.abs(d[ni+2]-bm.b)<tol && d[ni+3]>=50) {
vis[np] = 1; q.push(np);
}
}
}
const pad = 5;
return { x: Math.max(0, x0-pad), y: Math.max(0, y0-pad), w: Math.min(cw-1, x1+pad) - Math.max(0, x0-pad) + 1, h: Math.min(ch-1, y1+pad) - Math.max(0, y0-pad) + 1 };
},
enterElement(el) {
if (!el.hasDetail || !el.detailRef) return;
this.saveCurrentCanvas();
const l2Id = el.detailRef, l1Data = this.levelData["L1"];
// 动态计算 L2 画布尺寸:取 L1 区域 bbox ×2,不设上限
let cW = 800, cH = 600;
if (l1Data && l1Data.imageData) {
const bbox = this.findColorBBox(l1Data.imageData, l1Data.canvasW, l1Data.canvasH, el.px, el.py, 60);
if (bbox) { cW = Math.max(200, bbox.w * 2); cH = Math.max(150, bbox.h * 2); }
}
// 如果 L2 尚未创建或尺寸不同,更新
if (!this.levelData[l2Id]) {
this.initLevel(l2Id, 80, 60, cW, cH);
this.levelData[l2Id].parentLevel = "L1";
this.levelData[l2Id].parentElement = el.id;
}
const l2d = this.levelData[l2Id];
// 如果已有数据但尺寸变了,以新尺寸为准(仅当尚未绘制时)
if (!l2d.imageData) { l2d.canvasW = cW; l2d.canvasH = cH; }
this.currentLevel = l2Id;
this.setupCanvasSize(l2d.canvasW, l2d.canvasH);
// L2 绘图层:恢复旧数据或填透明
const ctx = this.dc.getContext("2d");
if (l2d.imageData) {
ctx.putImageData(l2d.imageData, 0, 0);
} else {
ctx.clearRect(0, 0, l2d.canvasW, l2d.canvasH);
}
// L1 底图层:裁剪 L1 区域缩放填充
const l1ctx = this.l1c.getContext("2d");
l1ctx.clearRect(0, 0, l2d.canvasW, l2d.canvasH);
if (l1Data && l1Data.imageData) {
const bbox = this.findColorBBox(l1Data.imageData, l1Data.canvasW, l1Data.canvasH, el.px, el.py, 60);
if (bbox) {
const tmp = document.createElement("canvas");
tmp.width = l1Data.canvasW; tmp.height = l1Data.canvasH;
tmp.getContext("2d").putImageData(l1Data.imageData, 0, 0);
l1ctx.drawImage(tmp, bbox.x, bbox.y, bbox.w, bbox.h, 0, 0, l2d.canvasW, l2d.canvasH);
}
}
l2d.l1BaseData = l1ctx.getImageData(0, 0, l2d.canvasW, l2d.canvasH);
this.undoStack = []; this.redoStack = []; this.updateUndoButtons();
this.wW.value = l2d.worldW; this.wH.value = l2d.worldH;
this.drawGrid(); this.renderBrushPalette(); this.updateBreadcrumb(); this.updateElementsList(); this.updateScaleDisplay(); this.updateStatus();
},
compositeL2BackToL1() {
const l2d = this.levelData[this.currentLevel];
if (!l2d || !l2d.parentLevel || !l2d.parentElement) return;
const l1d = this.levelData[l2d.parentLevel]; if (!l1d) return;
const pEl = l1d.elements.find(e => e.id === l2d.parentElement); if (!pEl) return;
const bbox = this.findColorBBox(l1d.imageData, l1d.canvasW, l1d.canvasH, pEl.px, pEl.py, 60);
if (!bbox) return;
this.saveCurrentCanvas();
// 直接把 L2 drawing-canvas(透明背景+用户笔触)缩放回写到 L1
// 透明像素不影响 L1 原内容,只有实际笔触会叠加
const l1tmp = document.createElement("canvas");
l1tmp.width = l1d.canvasW; l1tmp.height = l1d.canvasH;
const l1ctx = l1tmp.getContext("2d");
l1ctx.putImageData(l1d.imageData, 0, 0);
l1ctx.drawImage(this.dc, 0, 0, l2d.canvasW, l2d.canvasH, bbox.x, bbox.y, bbox.w, bbox.h);
l1d.imageData = l1ctx.getImageData(0, 0, l1d.canvasW, l1d.canvasH);
},
/* ---- 元素命名 ---- */
matchBrushColor(r, g, b) {
for (const br of BRUSH_RGB) {
if (Math.abs(r-br.r)<40 && Math.abs(g-br.g)<40 && Math.abs(b-br.b)<40) return br;
}
return null;
},
findElementAt(px, py) {
const d = this.levelData[this.currentLevel];
let best = null, dist = 50;
for (const el of d.elements) {
const d2 = Math.hypot(px-el.px, py-el.py);
if (d2 < dist) { dist = d2; best = el; }
}
return best;
},
/* 默认高度(米),按画笔类型 */
defaultHeightForType(type) {
return { building: 3, blocked: 3, zone: 3, greenery: 2, road: 0, water: 0 }[type] || 0;
},
showNameModal(px, py) {
const pixel = this.dc.getContext("2d").getImageData(px, py, 1, 1).data;
if (pixel[3] < 50) return;
const brushType = this.matchBrushColor(pixel[0], pixel[1], pixel[2]);
if (!brushType) return;
const existing = this.findElementAt(px, py);
const modal = document.getElementById("name-modal");
const inp = document.getElementById("name-input");
const chk = document.getElementById("name-enterable");
const heightRow = document.getElementById("name-height-row");
const heightInp = document.getElementById("name-height");
const connRow = document.getElementById("name-connections-row");
const connList = document.getElementById("name-connections-list");
inp.value = existing ? existing.name : "";
chk.checked = existing ? !!existing.hasDetail : false;
// L2 层不显示"可进入"复选框(L3 未实现)
chk.parentElement.style.display = this.currentLevel === "L1" ? "" : "none";
// 高度输入:building / blocked / zone 类型显示
const showHeight = ["building", "blocked", "zone"].includes(brushType.id);
heightRow.style.display = showHeight ? "flex" : "none";
if (showHeight) {
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
}
// 连通多选:列出同层级其他已命名元素
const d = this.levelData[this.currentLevel];
const others = d.elements.filter(e => e !== existing && e.name);
if (others.length > 0) {
connRow.style.display = "";
connList.innerHTML = "";
const existingConns = existing ? (existing.connections || []) : [];
others.forEach(o => {
const lbl = document.createElement("label");
const cb = document.createElement("input");
cb.type = "checkbox"; cb.value = o.id;
cb.checked = existingConns.includes(o.id);
const sw = document.createElement("span");
sw.className = "el-swatch"; sw.style.background = o.color; sw.style.width = "12px"; sw.style.height = "12px";
const txt = document.createElement("span"); txt.textContent = o.name;
lbl.append(cb, sw, txt);
connList.appendChild(lbl);
});
} else {
connRow.style.display = "none";
connList.innerHTML = "";
}
modal.style.display = "block"; inp.focus();
const ok = document.getElementById("name-ok"), cancel = document.getElementById("name-cancel");
const nOk = ok.cloneNode(true), nCancel = cancel.cloneNode(true);
ok.parentNode.replaceChild(nOk, ok); cancel.parentNode.replaceChild(nCancel, cancel);
const cleanup = () => { modal.style.display = "none"; };
nOk.addEventListener("click", () => {
const name = inp.value.trim(); if (!name) { cleanup(); return; }
const hasDetail = chk.checked;
const height_m = showHeight ? parseFloat(heightInp.value) || 0 : null;
const connIds = Array.from(connList.querySelectorAll("input:checked")).map(cb => cb.value);
if (existing) {
existing.name = name; existing.hasDetail = hasDetail;
existing.height_m = height_m;
existing.connections = connIds;
} else {
const el = {
id: "el_"+Date.now(), type: brushType.id, name, color: brushType.hex, px, py,
hasDetail, detailRef: null,
height_m, connections: connIds,
};
this.levelData[this.currentLevel].elements.push(el);
if (hasDetail && this.currentLevel === "L1") {
const l2Id = "L2-" + el.id;
this.initLevel(l2Id, 80, 60, 800, 600);
this.levelData[l2Id].parentLevel = "L1";
this.levelData[l2Id].parentElement = el.id;
el.detailRef = l2Id;
}
}
// 双向连接:同步到被连通的元素
connIds.forEach(cId => {
const target = d.elements.find(e => e.id === cId);
if (target) {
if (!target.connections) target.connections = [];
const elId = existing ? existing.id : this.levelData[this.currentLevel].elements[this.levelData[this.currentLevel].elements.length - 1].id;
if (!target.connections.includes(elId)) target.connections.push(elId);
}
});
this.updateElementsList(); this.drawElementLabels(); cleanup();
});
nCancel.addEventListener("click", cleanup);
},
/* ---- 元素列表 ---- */
updateElementsList() {
const list = document.getElementById("elements-list");
const d = this.levelData[this.currentLevel];
if (!d) { list.innerHTML = ""; return; }
list.innerHTML = "";
d.elements.forEach(el => {
const div = document.createElement("div"); div.className = "el-item";
div.innerHTML = `<div class="el-swatch" style="background:${el.color}"></div><span class="el-name">${el.name}</span>`;
if (el.hasDetail) {
const enter = document.createElement("span"); enter.className = "el-enter"; enter.textContent = "进入 →";
enter.addEventListener("click", e => { e.stopPropagation(); this.enterElement(el); });
div.appendChild(enter);
}
div.addEventListener("click", () => this.flashElement(el));
list.appendChild(div);
});
},
flashElement(el) {
const ctx = this.uc.getContext("2d"), d = this.levelData[this.currentLevel];
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(el.px, el.py, 20, 0, Math.PI*2); ctx.stroke();
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(el.px, el.py, 20, 0, Math.PI*2); ctx.stroke();
setTimeout(() => ctx.clearRect(0, 0, d.canvasW, d.canvasH), 800);
},
drawElementLabels() {
const ctx = this.uc.getContext("2d"), d = this.levelData[this.currentLevel];
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
d.elements.forEach(el => {
ctx.font = "bold 12px sans-serif";
const tw = ctx.measureText(el.name).width + 8, th = 18;
const tx = el.px - tw/2, ty = el.py - 28;
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(tx, ty, tw, th);
ctx.fillStyle = "#fff"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(el.name, el.px, ty + th/2);
if (el.hasDetail) {
ctx.fillStyle = "rgba(74,158,255,0.8)";
ctx.beginPath(); ctx.arc(el.px + tw/2 - 2, ty + 2, 5, 0, Math.PI*2); ctx.fill();
}
});
},
/* ---- 保存 / 加载 ---- */
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.style.display = "none";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
},
/** 将 ImageData 转为 base64 PNG */
imageDataToBase64(imageData, w, h) {
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
tmp.getContext("2d").putImageData(imageData, 0, 0);
return tmp.toDataURL("image/png");
},
/** 从 base64 恢复 ImageData */
base64ToImageData(b64) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const tmp = document.createElement("canvas");
tmp.width = img.width; tmp.height = img.height;
const ctx = tmp.getContext("2d");
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, tmp.width, tmp.height));
};
img.src = b64;
});
},
async saveAll() {
this.saveCurrentCanvas();
const name = this.sceneName.replace(/\s+/g, "_");
const zip = new JSZip();
const sceneData = {
scene_name: this.sceneName, version: "v02",
created: new Date().toISOString().slice(0, 19),
origin: "SW corner, X+ East, Y+ North",
brush_legend: {},
levels: {}
};
BRUSHES.forEach(b => { if (b.hex) sceneData.brush_legend[b.hex] = { type: b.id, label: b.label, mi_material: b.mi, levels: b.levels }; });
const allowedTypes = this.currentLevel === "L1" ? SCAN_TYPES.L1 : SCAN_TYPES.L2;
for (const [id, d] of Object.entries(this.levelData)) {
const sx = d.worldW / d.canvasW, sy = d.worldH / d.canvasH;
const types = id === "L1" ? SCAN_TYPES.L1 : SCAN_TYPES.L2;
let regions = [];
if (d.imageData) regions = this.scanRegions(d.imageData, d.canvasW, d.canvasH, sx, sy, d.elements, types);
// 像素数据存为独立 PNG 文件,JSON 中只放文件名
let imageFile = null, l1BaseFile = null;
if (d.imageData) {
imageFile = `${name}-${id}-drawing.png`;
const blob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
tmp.getContext("2d").putImageData(d.imageData, 0, 0);
tmp.toBlob(r, "image/png");
});
zip.file(imageFile, blob);
}
if (d.l1BaseData) {
l1BaseFile = `${name}-${id}-l1base.png`;
const blob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
tmp.getContext("2d").putImageData(d.l1BaseData, 0, 0);
tmp.toBlob(r, "image/png");
});
zip.file(l1BaseFile, blob);
}
sceneData.levels[id] = {
world_size_m: [d.worldW, d.worldH],
canvas_size_px: [d.canvasW, d.canvasH],
scale_m_per_px: parseFloat(sx.toFixed(4)),
elements: d.elements.map(el => ({
id: el.id, type: el.type, name: el.name, color: el.color, px: el.px, py: el.py,
world_x_m: parseFloat((el.px * sx).toFixed(1)),
world_y_m: parseFloat(((d.canvasH - el.py) * sy).toFixed(1)),
has_detail: !!el.hasDetail, detail_ref: el.detailRef || null,
height_m: el.height_m != null ? el.height_m : null,
connections: el.connections || [],
})),
regions,
image_file: imageFile,
l1_base_file: l1BaseFile,
parent_level: d.parentLevel || null,
parent_element: d.parentElement || null,
};
// 合并预览 PNG(L1 底图 + 绘图层,供人查看)
if (d.imageData) {
const pngBlob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
const ctx = tmp.getContext("2d");
if (d.l1BaseData) {
const baseTmp = document.createElement("canvas");
baseTmp.width = d.canvasW; baseTmp.height = d.canvasH;
baseTmp.getContext("2d").putImageData(d.l1BaseData, 0, 0);
ctx.drawImage(baseTmp, 0, 0);
}
const drawTmp = document.createElement("canvas");
drawTmp.width = d.canvasW; drawTmp.height = d.canvasH;
drawTmp.getContext("2d").putImageData(d.imageData, 0, 0);
ctx.drawImage(drawTmp, 0, 0);
tmp.toBlob(r, "image/png");
});
const tag = id === "L1" ? "L1-overview" : id;
zip.file(`${name}-${tag}-preview.png`, pngBlob);
}
}
zip.file(`${name}-scene.json`, JSON.stringify(sceneData, null, 2));
const blob = await zip.generateAsync({ type: "blob" });
this.downloadBlob(blob, `${name}-scene.zip`);
this.showSaveFeedback("ZIP 已保存 ✓");
},
showSaveFeedback(msg) {
const el = document.getElementById("status-feedback");
el.textContent = msg; el.style.opacity = "1";
setTimeout(() => { el.style.opacity = "0"; setTimeout(() => { el.textContent = ""; }, 300); }, 2000);
},
async onFileLoad(e) {
const file = e.target.files[0]; if (!file) return;
e.target.value = "";
try {
if (file.name.endsWith(".zip")) {
const ab = await file.arrayBuffer();
const zip = await JSZip.loadAsync(ab);
const jsonFile = Object.keys(zip.files).find(n => n.endsWith("-scene.json"));
if (!jsonFile) { alert("ZIP 中未找到 scene.json"); return; }
const txt = await zip.file(jsonFile).async("string");
await this.restoreScene(JSON.parse(txt), zip);
} else {
const txt = await file.text();
await this.restoreScene(JSON.parse(txt), null);
}
this.showSaveFeedback("加载成功 ✓");
} catch (err) {
alert("加载失败:" + err.message);
}
},
/** 从 PNG Blob 恢复 ImageData */
blobToImageData(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const tmp = document.createElement("canvas");
tmp.width = img.width; tmp.height = img.height;
const ctx = tmp.getContext("2d");
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, tmp.width, tmp.height));
};
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
},
async restoreScene(sceneData, zip) {
this.sceneName = sceneData.scene_name || "新场景";
document.getElementById("scene-name-input").value = this.sceneName;
this.levelData = {};
for (const [id, lvl] of Object.entries(sceneData.levels)) {
this.levelData[id] = {
worldW: lvl.world_size_m[0], worldH: lvl.world_size_m[1],
canvasW: lvl.canvas_size_px[0], canvasH: lvl.canvas_size_px[1],
elements: (lvl.elements || []).map(el => ({
id: el.id, type: el.type, name: el.name, color: el.color,
px: el.px, py: el.py, hasDetail: el.has_detail, detailRef: el.detail_ref,
height_m: el.height_m != null ? el.height_m : null,
connections: el.connections || [],
})),
imageData: null, l1BaseData: null,
parentLevel: lvl.parent_level || null, parentElement: lvl.parent_element || null,
};
// 从 ZIP 中的 PNG 文件恢复像素数据
if (zip && lvl.image_file && zip.files[lvl.image_file]) {
const blob = await zip.files[lvl.image_file].async("blob");
this.levelData[id].imageData = await this.blobToImageData(blob);
} else if (lvl.image_data) {
// 向后兼容:旧格式 base64 内嵌
this.levelData[id].imageData = await this.base64ToImageData(lvl.image_data);
}
if (zip && lvl.l1_base_file && zip.files[lvl.l1_base_file]) {
const blob = await zip.files[lvl.l1_base_file].async("blob");
this.levelData[id].l1BaseData = await this.blobToImageData(blob);
} else if (lvl.l1_base_data) {
this.levelData[id].l1BaseData = await this.base64ToImageData(lvl.l1_base_data);
}
}
const first = this.levelData["L1"] ? "L1" : Object.keys(this.levelData)[0];
if (first) this.switchToLevel(first);
},
/* ---- 像素扫描 ---- */
scanRegions(imageData, cw, ch, scaleX, scaleY, elements, allowedTypes) {
const data = imageData.data;
const vis = new Uint8Array(cw * ch);
const regions = [];
let rid = 0;
const step = cw > 600 ? 2 : 1;
for (let y = 0; y < ch; y += step) {
for (let x = 0; x < cw; x += step) {
const pos = y * cw + x;
if (vis[pos]) continue;
const idx = pos * 4;
if (data[idx+3] < 50) { vis[pos] = 1; continue; }
if (data[idx] > 220 && data[idx+1] > 220 && data[idx+2] > 220 && Math.abs(data[idx]-data[idx+1]) < 30 && Math.abs(data[idx+1]-data[idx+2]) < 30) { vis[pos] = 1; continue; }
const brush = this.matchBrushColor(data[idx], data[idx+1], data[idx+2]);
if (!brush || !allowedTypes.includes(brush.id)) { vis[pos] = 1; continue; }
rid++;
const q = [pos]; vis[pos] = rid;
let x0=x, x1=x, y0=y, y1=y, area=0;
while (q.length) {
const cur = q.pop(); area++;
const cx = cur % cw, cy = (cur-cx)/cw;
if (cx<x0) x0=cx; if (cx>x1) x1=cx; if (cy<y0) y0=cy; if (cy>y1) y1=cy;
for (const np of [cx>0?cur-1:-1, cx<cw-1?cur+1:-1, cy>0?cur-cw:-1, cy<ch-1?cur+cw:-1]) {
if (np<0||vis[np]) continue;
const ni=np*4;
if (data[ni+3]<50) { vis[np]=255; continue; }
if (Math.abs(data[ni]-brush.r)<40 && Math.abs(data[ni+1]-brush.g)<40 && Math.abs(data[ni+2]-brush.b)<40) {
vis[np]=rid; q.push(np);
} else vis[np]=255;
}
}
if (area * step * step < 20) continue;
const elName = elements.find(el => el.px>=x0 && el.px<=x1 && el.py>=y0 && el.py<=y1);
regions.push({
id: `r_${String(rid).padStart(3,"0")}`, type: brush.id, color: brush.hex,
name: elName ? elName.name : null,
bounds_px: [x0, y0, x1, y1],
bounds_world_m: [
parseFloat((x0*scaleX).toFixed(1)), parseFloat(((ch-y1)*scaleY).toFixed(1)),
parseFloat((x1*scaleX).toFixed(1)), parseFloat(((ch-y0)*scaleY).toFixed(1)),
],
area_px: area * step * step,
});
}
}
return regions;
},
/* ---- 键盘 ---- */
initKeyboard() {
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
if (e.ctrlKey && e.key === "z") { e.preventDefault(); this.undo(); }
if (e.ctrlKey && e.key === "y") { e.preventDefault(); this.redo(); }
if (e.key === "p" || e.key === "P") { this.currentTool = "pen"; document.querySelectorAll("[data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === "pen")); }
if (e.key === "e" || e.key === "E") { this.currentBrush = "eraser"; this.renderBrushPalette(); }
if (e.key === "g" || e.key === "G") { this.gridVisible = !this.gridVisible; document.getElementById("grid-toggle").checked = this.gridVisible; this.drawGrid(); }
});
},
};
window.addEventListener("DOMContentLoaded", () => App.init());
</script>
</body>
</html>