Files
Uni-Lab-OS/unilabos/layout_optimizer/static/lab3d.html
2026-03-31 09:30:40 +08:00

1228 lines
51 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="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lab3D Designer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0D1117; color: #F0F6FC; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; height: 100vh; overflow: hidden; user-select: none; }
/* ── App Shell ─────────────────────────────── */
#app { height: 100vh; display: flex; flex-direction: column; }
/* ── Toolbar ───────────────────────────────── */
#toolbar {
height: 48px; min-height: 48px;
background: #161B22;
border-bottom: 1px solid #30363D;
display: flex; align-items: center; gap: 12px; padding: 0 16px;
}
.logo { display: flex; align-items: center; gap: 8px; }
.logo-icon { width: 24px; height: 24px; background: #00D4FF; border-radius: 4px; position: relative; flex-shrink: 0; }
.logo-icon::after { content: ''; position: absolute; width: 12px; height: 12px; background: #0D1117; border-radius: 2px; top: 6px; left: 6px; }
.logo-text { font-size: 16px; font-weight: 600; color: #F0F6FC; }
.logo-text span { color: #00D4FF; }
.tb-sep { width: 1px; height: 24px; background: #30363D; flex-shrink: 0; }
.tb-label { font-size: 13px; color: #8B949E; }
.tb-spacer { flex: 1; }
.btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: opacity .15s, background .15s; }
.btn:active { opacity: .8; }
.btn-primary { background: #00D4FF; color: #0D1117; font-weight: 600; border-color: #00D4FF; }
.btn-primary:hover { background: #00BBDF; }
.btn-ghost { background: #1C2128; color: #8B949E; border-color: #30363D; }
.btn-ghost:hover { color: #F0F6FC; border-color: #6E7681; }
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
/* ── Main Body ─────────────────────────────── */
#main { flex: 1; display: flex; overflow: hidden; }
/* ── Sidebar ───────────────────────────────── */
#sidebar {
width: 260px; min-width: 260px;
background: #161B22;
border-right: 1px solid #30363D;
display: flex; flex-direction: column;
}
.panel-header {
height: 48px; min-height: 48px;
display: flex; align-items: center; gap: 8px; padding: 0 16px;
border-bottom: 1px solid #30363D;
font-size: 13px; font-weight: 600; color: #F0F6FC;
}
.panel-header .header-spacer { flex: 1; }
.count-badge {
display: inline-flex; align-items: center; padding: 0 6px; height: 18px;
background: #1C2128; border: 1px solid #30363D; border-radius: 9px;
font-size: 11px; color: #8B949E; font-weight: 500;
}
.count-badge.active { border-color: #00D4FF; color: #00D4FF; }
.device-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.device-list::-webkit-scrollbar { width: 4px; }
.device-list::-webkit-scrollbar-track { background: transparent; }
.device-list::-webkit-scrollbar-thumb { background: #30363D; border-radius: 2px; }
.device-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; border-radius: 4px;
cursor: pointer; transition: background .12s;
font-size: 12px; color: #C9D1D9;
}
.device-row:hover { background: #1C2128; }
.device-row .dr-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-row .dr-size { font-size: 10px; color: #8B949E; white-space: nowrap; flex-shrink: 0; }
.device-row .dr-add {
width: 18px; height: 18px; border-radius: 3px;
border: 1px solid #30363D; background: transparent;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: #8B949E; font-size: 14px; line-height: 1;
transition: border-color .12s, color .12s;
}
.device-row:hover .dr-add { border-color: #00D4FF; color: #00D4FF; }
/* ── Viewport ──────────────────────────────── */
#viewport-wrap { flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; }
#vp-tabs {
height: 36px; min-height: 36px; background: #0D1117;
border-bottom: 1px solid #1E2530;
display: flex; align-items: center; gap: 4px; padding: 0 12px;
}
.vp-tab {
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px;
border-radius: 4px; font-size: 12px; color: #8B949E; cursor: pointer;
transition: background .12s, color .12s;
}
.vp-tab.active { background: #161B22; color: #F0F6FC; border: 1px solid #30363D; }
.vp-tab svg { width: 12px; height: 12px; }
.vp-spacer { flex: 1; }
.vp-ctrl-group { display: flex; gap: 4px; }
.vp-ctrl {
width: 28px; height: 28px; background: #161B22; border: 1px solid #30363D;
border-radius: 4px; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #8B949E; transition: color .12s, border-color .12s;
}
.vp-ctrl:hover { color: #F0F6FC; border-color: #6E7681; }
.vp-ctrl svg { width: 14px; height: 14px; }
#canvas-container { flex: 1; position: relative; overflow: hidden; background: #080C10; }
#three-canvas { width: 100%; height: 100%; display: block; }
#css2d-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#empty-state {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
display: flex; flex-direction: column; align-items: center; gap: 12px;
pointer-events: none; transition: opacity .3s;
}
#empty-state.hidden { opacity: 0; pointer-events: none; }
.empty-icon { color: #30363D; }
.empty-icon svg { width: 48px; height: 48px; }
.empty-title { font-size: 16px; font-weight: 600; color: #8B949E; }
.empty-desc { font-size: 12px; color: #8B949E; text-align: center; max-width: 280px; line-height: 1.5; }
.empty-hint {
display: inline-flex; align-items: center; gap: 6px;
height: 32px; padding: 0 16px; border-radius: 6px;
background: #161B22; border: 1px solid #30363D;
font-size: 13px; color: #8B949E; pointer-events: all; cursor: pointer;
}
.empty-hint:hover { color: #F0F6FC; border-color: #6E7681; }
/* ── Right Panel ───────────────────────────── */
#rpanel {
width: 240px; min-width: 240px;
background: #161B22;
border-left: 1px solid #30363D;
display: flex; flex-direction: column;
}
.rpanel-body { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 0; }
.rpanel-body::-webkit-scrollbar { width: 4px; }
.rpanel-body::-webkit-scrollbar-thumb { background: #30363D; border-radius: 2px; }
.section-label {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 600; color: #8B949E;
text-transform: uppercase; letter-spacing: .5px;
margin-bottom: 10px;
}
.section-label::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--dot-color, #00D4FF); flex-shrink: 0; }
.selected-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; min-height: 48px; }
.selected-empty { font-size: 12px; color: #6E7681; font-style: italic; padding: 8px 0; }
.selected-item {
display: flex; align-items: center; gap: 8px;
padding: 10px; background: #1C2128; border-radius: 6px;
border: 1px solid #00D4FF33;
}
.sel-icon {
width: 28px; height: 28px; border-radius: 4px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.sel-icon svg { width: 14px; height: 14px; }
.sel-info { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sel-name { font-size: 12px; font-weight: 600; color: #F0F6FC; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sel-pos { font-size: 10px; color: #8B949E; }
.sel-del { color: #8B949E; cursor: pointer; flex-shrink: 0; transition: color .12s; }
.sel-del:hover { color: #F0F6FC; }
.sel-del svg { width: 12px; height: 12px; display: block; }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.field-label { font-size: 11px; color: #8B949E; }
.field-input {
width: 100%; height: 32px; background: #1C2128; border: 1px solid #30363D;
border-radius: 6px; color: #F0F6FC; font-size: 12px; padding: 0 10px;
display: flex; align-items: center; gap: 6px;
}
.field-input input {
background: transparent; border: none; outline: none;
color: #F0F6FC; font-size: 13px; width: 100%;
}
.field-input .unit { font-size: 11px; color: #8B949E; flex-shrink: 0; }
.field-select {
width: 100%; height: 32px; background: #1C2128; border: 1px solid #30363D;
border-radius: 6px; color: #F0F6FC; font-size: 12px; padding: 0 10px;
appearance: none; -webkit-appearance: none; outline: none; cursor: pointer;
}
.rpanel-divider { height: 1px; background: #30363D; margin: 16px 0; }
.run-btn {
width: 100%; height: 36px; background: #00D4FF; border: none;
border-radius: 6px; color: #0D1117; font-size: 13px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px;
transition: background .15s, opacity .15s;
}
.run-btn:hover { background: #00BBDF; }
.run-btn:disabled { opacity: .5; cursor: not-allowed; }
.run-btn svg { width: 14px; height: 14px; }
.run-btn.running { background: #1C2128; color: #8B949E; border: 1px solid #30363D; }
.cost-badge {
display: flex; align-items: center; gap: 6px; margin-top: 10px;
padding: 8px 10px; border-radius: 6px; font-size: 11px;
}
.cost-badge.success { background: #3FB95022; border: 1px solid #3FB95044; color: #3FB950; }
.cost-badge.fail { background: #F8514922; border: 1px solid #F8514944; color: #F85149; }
.cost-badge.info { background: #1C2128; border: 1px solid #30363D; color: #8B949E; }
.cost-badge svg { width: 12px; height: 12px; flex-shrink: 0; }
/* ── Status Bar ────────────────────────────── */
#statusbar {
height: 28px; min-height: 28px; background: #0D1117;
border-top: 1px solid #1E2530;
display: flex; align-items: center; gap: 16px; padding: 0 16px;
font-size: 11px;
}
.sb-item { display: flex; align-items: center; gap: 6px; color: #8B949E; }
.sb-dot { width: 6px; height: 6px; border-radius: 50%; background: #3FB950; }
.sb-dot.warn { background: #F0A84E; }
.sb-sep { width: 1px; height: 14px; background: #30363D; }
.sb-spacer { flex: 1; }
#sb-status { color: #3FB950; }
/* ── Device Labels in 3D ───────────────────── */
.device-label {
background: #0D1117CC; border: 1px solid #30363D;
border-radius: 4px; padding: 3px 7px;
font-size: 11px; font-weight: 600; color: #F0F6FC;
white-space: nowrap; pointer-events: none;
backdrop-filter: blur(4px);
}
/* ── Toast ─────────────────────────────────── */
#toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: #161B22; border: 1px solid #30363D; border-radius: 8px;
padding: 10px 16px; font-size: 13px; color: #F0F6FC;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 8px 24px #00000066;
opacity: 0; pointer-events: none; transition: opacity .25s;
z-index: 9999;
}
#toast.show { opacity: 1; }
#toast svg { width: 14px; height: 14px; flex-shrink: 0; }
</style>
</head>
<body>
<div id="app">
<!-- Toolbar -->
<div id="toolbar">
<div class="logo">
<div class="logo-icon"></div>
<div class="logo-text">Lab3D <span>Designer</span></div>
</div>
<div class="tb-sep"></div>
<div class="tb-label">3D View</div>
<div class="tb-spacer"></div>
<button class="btn btn-primary" id="btn-autolayout" onclick="runLayout()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Auto Layout
</button>
<button class="btn btn-ghost" id="btn-clear" onclick="clearAll()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Clear
</button>
</div>
<!-- Main -->
<div id="main">
<!-- Sidebar -->
<div id="sidebar">
<div class="panel-header">
Device Library
<div class="header-spacer"></div>
<span class="count-badge" id="device-count">0 selected</span>
</div>
<div style="padding: 8px 12px 0; display:flex; flex-direction:column; gap:6px;">
<input id="device-search" type="text" placeholder="Search devices..."
style="width:100%; height:28px; padding:0 10px; border-radius:4px; border:1px solid #30363D;
background:#1C2128; color:#F0F6FC; font-size:12px; outline:none;" />
<label style="display:flex; align-items:center; gap:6px; font-size:11px; color:#8B949E; cursor:pointer;">
<input type="checkbox" id="filter-devices-only" checked
style="accent-color:#00D4FF;" />
Devices only (hide consumables)
</label>
</div>
<div class="device-list" id="device-list">
<!-- 动态渲染:由 renderDeviceCards() 填充 -->
</div>
</div>
<!-- Viewport -->
<div id="viewport-wrap">
<div id="vp-tabs">
<div class="vp-tab active" id="tab-3d" onclick="setView('3d')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
3D Scene
</div>
<div class="vp-tab" id="tab-top" onclick="setView('top')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
Top View
</div>
<div class="vp-spacer"></div>
<div class="vp-ctrl-group">
<div class="vp-ctrl" onclick="resetCamera()" title="Reset Camera">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 005.64 5.64L1 10M23 14l-4.64 4.36A9 9 0 013.51 15"/></svg>
</div>
<div class="vp-ctrl" onclick="zoomFit()" title="Zoom to Fit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>
</div>
<div class="vp-ctrl" id="toggle-grid" onclick="toggleGrid()" title="Toggle Grid">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</div>
</div>
</div>
<div id="canvas-container">
<canvas id="three-canvas"></canvas>
<div id="css2d-container"></div>
<div id="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
</div>
<div class="empty-title">Lab is Empty</div>
<div class="empty-desc">Select up to 3 devices from the library, then click Auto Layout to place them</div>
<div class="empty-hint" onclick="window.addDevice(Object.keys(CATALOG).find(k=>!CATALOG[k].isConsumable) || Object.keys(CATALOG)[0])">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add first device
</div>
</div>
</div>
</div>
<!-- Right Panel -->
<div id="rpanel">
<div class="panel-header">
Scene
<div class="header-spacer"></div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:#8B949E"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 010 14.14M4.93 4.93a10 10 0 000 14.14"/></svg>
</div>
<div class="rpanel-body">
<div class="section-label" style="--dot-color:#00D4FF">Selected Devices</div>
<div class="selected-list" id="selected-list">
<div class="selected-empty" id="selected-empty">No devices selected</div>
</div>
<div class="rpanel-divider"></div>
<div class="section-label" style="--dot-color:#3FB950">Layout Settings</div>
<div class="field-row">
<div class="field-label">Lab Size</div>
<div class="field-input">
<input type="number" id="lab-w" value="4" min="2" max="20" step="0.5" style="width:40px" />
<span class="unit">×</span>
<input type="number" id="lab-d" value="4" min="2" max="20" step="0.5" style="width:40px" />
<span class="unit">m</span>
</div>
</div>
<div class="field-row">
<div class="field-label">Collision Margin</div>
<div class="field-input">
<input type="number" id="margin" value="50" min="0" max="500" step="10" />
<span class="unit">mm</span>
</div>
</div>
<div class="field-row">
<div class="field-label">Algorithm</div>
<select class="field-select" id="algorithm">
<option value="backend">Differential Evolution (backend)</option>
<option value="js">2D Bin Packing (local)</option>
</select>
</div>
<div class="field-row">
<div class="field-label">Layout Preset</div>
<select class="field-select" id="seeder-select">
<option value="compact_outward" selected>Compact Outward</option>
<option value="spread_inward">Spread Inward</option>
<option value="row_fallback">Row Fallback</option>
</select>
</div>
<div class="field-row">
<div class="field-label">Options</div>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ccc;cursor:pointer;">
<input type="checkbox" id="run-de-checkbox" checked style="accent-color:#4fc3f7;" />
Run Differential Evolution
</label>
</div>
<div class="field-row">
<div class="field-label">Orientation Lock</div>
<div class="field-input" style="display:flex;align-items:center;gap:6px;">
<input type="range" id="orientation-weight" min="0" max="20" step="1" value="5"
style="flex:1;accent-color:#4fc3f7;" oninput="document.getElementById('ow-val').textContent=this.value" />
<span id="ow-val" style="font-size:11px;color:#aaa;min-width:16px;text-align:right;">5</span>
</div>
</div>
<div class="field-row">
<div class="field-label">Angle Snap</div>
<div class="field-input" style="display:flex;align-items:center;gap:6px;">
<input type="range" id="align-weight" min="0" max="10" step="0.5" value="2"
style="flex:1;accent-color:#4fc3f7;" oninput="document.getElementById('aw-val').textContent=this.value" />
<span id="aw-val" style="font-size:11px;color:#aaa;min-width:16px;text-align:right;">2</span>
</div>
</div>
<div class="rpanel-divider"></div>
<button class="run-btn" id="run-btn" onclick="runLayout()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Auto Layout
</button>
<div id="result-badge" style="display:none"></div>
</div>
</div>
</div>
<!-- Status Bar -->
<div id="statusbar">
<div class="sb-item">
<div class="sb-dot" id="sb-dot"></div>
<span id="sb-status">Ready</span>
</div>
<div class="sb-sep"></div>
<div class="sb-item" id="sb-coords">x: — &nbsp; z: —</div>
<div class="sb-spacer"></div>
<div class="sb-item" id="sb-devices">Devices: 0</div>
<div class="sb-sep"></div>
<div class="sb-item" id="sb-zoom">Zoom: 100%</div>
<div class="sb-sep"></div>
<div class="sb-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Grid: 0.5 m
</div>
</div>
</div>
<div id="toast"></div>
<!-- ──────────────────────────────────────────── -->
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.169.0",
"three/addons/": "https://esm.sh/three@0.169.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
/* ── Device Catalog ─────────────────────────── */
// 颜色池:为动态加载的设备分配颜色
const COLOR_POOL = [
{ color: 0x00D4FF, emissive: 0x003A4A, hexStr: '#00D4FF' },
{ color: 0x7C3AED, emissive: 0x1A0F33, hexStr: '#7C3AED' },
{ color: 0xF0A84E, emissive: 0x331A00, hexStr: '#F0A84E' },
{ color: 0x3FB950, emissive: 0x0A2612, hexStr: '#3FB950' },
{ color: 0xF85149, emissive: 0x330A08, hexStr: '#F85149' },
{ color: 0xD2A8FF, emissive: 0x1A0F33, hexStr: '#D2A8FF' },
{ color: 0x79C0FF, emissive: 0x0A1929, hexStr: '#79C0FF' },
{ color: 0xFFA657, emissive: 0x331A00, hexStr: '#FFA657' },
];
// 硬编码后备目录(当 /devices 不可用时)
const FALLBACK_CATALOG = {
ot2: {
id: 'ot2', name: 'Opentrons OT-2',
size: [0.62, 0.50], height: 0.68,
color: 0x00D4FF, emissive: 0x003A4A, hexStr: '#00D4FF',
},
shaker: {
id: 'shaker', name: 'Orbital Shaker',
size: [0.30, 0.30], height: 0.22,
color: 0x7C3AED, emissive: 0x1A0F33, hexStr: '#7C3AED',
},
heatbath: {
id: 'heatbath', name: 'Heat Bath',
size: [0.40, 0.25], height: 0.28,
color: 0xF0A84E, emissive: 0x331A00, hexStr: '#F0A84E',
},
};
let CATALOG = {}; // 初始化后由 loadDeviceCatalog() 填充
async function loadDeviceCatalog() {
try {
const resp = await fetch('/devices');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const devices = await resp.json();
if (!devices.length) throw new Error('Empty device list');
CATALOG = {};
devices.forEach((d, i) => {
const colors = COLOR_POOL[i % COLOR_POOL.length];
const w = d.bbox?.[0] || 0.6, dep = d.bbox?.[1] || 0.4;
CATALOG[d.id] = {
id: d.id,
name: d.name || d.id,
size: [w, dep],
height: d.height || 0.4,
origin_offset: d.origin_offset || [0, 0],
openings: d.openings || [],
model_path: d.model_path || '',
model_type: d.model_type || '',
device_type: d.device_type || 'static',
source: d.source || 'manual',
isConsumable: d.is_standalone === false,
...colors,
};
});
console.info(`Loaded ${devices.length} devices from /devices`);
} catch (err) {
console.warn('Failed to load /devices, using fallback catalog:', err);
CATALOG = {};
for (const [k, v] of Object.entries(FALLBACK_CATALOG)) {
CATALOG[k] = { ...v, openings: [], origin_offset: [0, 0], isConsumable: false, device_type: 'static', source: 'fallback' };
}
}
renderDeviceCards();
}
/* ── App State ─────────────────────────────── */
// selected: array of { device_id, uuid } 支持同一设备多次添加
const state = {
selected: [],
placements: {}, // uuid -> placement data
placed: false,
};
let _uuidCounter = 0;
function genUUID() {
return `dev_${Date.now()}_${++_uuidCounter}`;
}
/* ── Three.js Setup ─────────────────────────── */
const container = document.getElementById('canvas-container');
const canvas = document.getElementById('three-canvas');
const css2dDiv = document.getElementById('css2d-container');
// Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.9;
// CSS2D Renderer (for labels)
const labelRenderer = new CSS2DRenderer({ element: css2dDiv });
labelRenderer.setSize(container.clientWidth, container.clientHeight);
// Scene
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080C10);
scene.fog = new THREE.FogExp2(0x080C10, 0.08);
// Camera
const camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.05, 100);
camera.position.set(3.5, 3.5, 4.5);
// Controls
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 1;
controls.maxDistance = 20;
controls.maxPolarAngle = Math.PI * 0.48;
controls.addEventListener('change', () => {
const d = camera.position.distanceTo(controls.target);
document.getElementById('sb-zoom').textContent = `Zoom: ${Math.round(100 / d * 3.5)}%`;
});
/* ── Lighting ───────────────────────────────── */
const ambientLight = new THREE.AmbientLight(0x1A1F2E, 3);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 10, 6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 30;
dirLight.shadow.camera.left = -8;
dirLight.shadow.camera.right = 8;
dirLight.shadow.camera.top = 8;
dirLight.shadow.camera.bottom = -8;
scene.add(dirLight);
const hemiLight = new THREE.HemisphereLight(0x223355, 0x0D1117, 0.4);
scene.add(hemiLight);
/* ── Lab Floor & Grid ───────────────────────── */
let gridHelper = null;
let labMesh = null;
let labBorderMesh = null;
function rebuildLab() {
const w = parseFloat(document.getElementById('lab-w').value) || 4;
const d = parseFloat(document.getElementById('lab-d').value) || 4;
if (labMesh) { scene.remove(labMesh); labMesh.geometry.dispose(); }
if (labBorderMesh) { scene.remove(labBorderMesh); labBorderMesh.geometry.dispose(); }
if (gridHelper) scene.remove(gridHelper);
// Floor
const floorGeo = new THREE.PlaneGeometry(w, d);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x0D1117, roughness: 0.9, metalness: 0.1 });
labMesh = new THREE.Mesh(floorGeo, floorMat);
labMesh.rotation.x = -Math.PI / 2;
labMesh.receiveShadow = true;
scene.add(labMesh);
// Border
const borderGeo = new THREE.EdgesGeometry(new THREE.PlaneGeometry(w, d));
const borderMat = new THREE.LineBasicMaterial({ color: 0x00D4FF, transparent: true, opacity: 0.6 });
labBorderMesh = new THREE.LineSegments(borderGeo, borderMat);
labBorderMesh.rotation.x = -Math.PI / 2;
labBorderMesh.position.y = 0.002;
scene.add(labBorderMesh);
// Grid
const cells = Math.round(Math.max(w, d) / 0.5);
gridHelper = new THREE.GridHelper(Math.max(w, d) * 1.5, cells * 3, 0x00D4FF, 0x1A2030);
gridHelper.material.transparent = true;
gridHelper.material.opacity = 0.25;
scene.add(gridHelper);
}
rebuildLab();
function pushLabSize() {
const w = parseFloat(document.getElementById('lab-w').value) || 4;
const d = parseFloat(document.getElementById('lab-d').value) || 4;
fetch('/scene/lab', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({width: w, depth: d}) }).catch(() => {});
}
document.getElementById('lab-w').addEventListener('change', () => { rebuildLab(); pushLabSize(); });
document.getElementById('lab-d').addEventListener('change', () => { rebuildLab(); pushLabSize(); });
pushLabSize(); // push on load
/* ── Device Meshes ──────────────────────────── */
const deviceGroup = new THREE.Group();
scene.add(deviceGroup);
const deviceMeshes = {}; // uuid -> THREE.Group
const deviceAnims = {}; // uuid -> {targetX, targetZ, targetTheta, active}
const deviceLabels = {}; // uuid -> CSS2DObject
function createDeviceMesh(deviceId, uuid) {
const def = CATALOG[deviceId];
if (!def) return null;
const [w, d] = def.size;
const h = def.height;
const group = new THREE.Group();
group.userData = { deviceId, uuid };
// Body — real bbox dimensions
const bodyGeo = new THREE.BoxGeometry(w, h, d);
const bodyMat = new THREE.MeshStandardMaterial({
color: def.color,
emissive: def.emissive,
roughness: 0.4,
metalness: 0.3,
transparent: true,
opacity: 0.88,
});
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = h / 2;
body.castShadow = true;
body.receiveShadow = true;
group.add(body);
// Edges
const edgesGeo = new THREE.EdgesGeometry(bodyGeo);
const edgesMat = new THREE.LineBasicMaterial({ color: def.color, transparent: true, opacity: 0.7 });
const edges = new THREE.LineSegments(edgesGeo, edgesMat);
edges.position.y = h / 2;
group.add(edges);
// Top face highlight
const topGeo = new THREE.PlaneGeometry(w * 0.9, d * 0.9);
const topMat = new THREE.MeshBasicMaterial({ color: def.color, transparent: true, opacity: 0.15, side: THREE.FrontSide });
const top = new THREE.Mesh(topGeo, topMat);
top.rotation.x = -Math.PI / 2;
top.position.y = h + 0.001;
group.add(top);
// Opening markers — arrow on the opening face
if (def.openings && def.openings.length > 0) {
for (const opening of def.openings) {
const [dx, dy] = opening.direction || [0, -1];
// Arrow triangle on the opening face
const arrowSize = Math.min(w, d) * 0.25;
const arrowShape = new THREE.Shape();
arrowShape.moveTo(0, arrowSize * 0.6);
arrowShape.lineTo(-arrowSize * 0.4, -arrowSize * 0.3);
arrowShape.lineTo(arrowSize * 0.4, -arrowSize * 0.3);
arrowShape.closePath();
const arrowGeo = new THREE.ShapeGeometry(arrowShape);
const arrowMat = new THREE.MeshBasicMaterial({
color: 0xFF6B35, side: THREE.DoubleSide, transparent: true, opacity: 0.9,
});
const arrow = new THREE.Mesh(arrowGeo, arrowMat);
// Position arrow on the face indicated by direction
// dx,dy is in 2D layout space. In Three.js: X=layout X, Z=layout Y
const midH = h * 0.5;
if (Math.abs(dx) > Math.abs(dy)) {
// Left or right face
arrow.position.set(dx > 0 ? w/2 + 0.005 : -w/2 - 0.005, midH, 0);
arrow.rotation.y = dx > 0 ? -Math.PI/2 : Math.PI/2;
arrow.rotation.z = dx > 0 ? 0 : 0;
} else {
// Front (dy<0 → -Z in three.js) or back (dy>0 → +Z)
arrow.position.set(0, midH, dy < 0 ? -d/2 - 0.005 : d/2 + 0.005);
arrow.rotation.x = Math.PI/2;
arrow.rotation.z = dy < 0 ? Math.PI : 0;
}
group.add(arrow);
// Opening strip — colored band along the opening edge
const stripThick = 0.012;
let stripW, stripH, stripX, stripY, stripZ, stripRotX, stripRotY;
if (Math.abs(dx) > Math.abs(dy)) {
stripW = stripThick; stripH = h * 0.8;
stripX = dx > 0 ? w/2 + 0.003 : -w/2 - 0.003;
stripY = midH; stripZ = 0;
stripRotX = 0; stripRotY = 0;
} else {
stripW = w * 0.8; stripH = stripThick;
stripX = 0; stripY = midH;
stripZ = dy < 0 ? -d/2 - 0.003 : d/2 + 0.003;
stripRotX = 0; stripRotY = 0;
}
const stripGeo = new THREE.PlaneGeometry(
Math.abs(dx) > Math.abs(dy) ? d * 0.8 : w * 0.8,
h * 0.8
);
const stripMat = new THREE.MeshBasicMaterial({
color: 0xFF6B35, side: THREE.DoubleSide, transparent: true, opacity: 0.3,
});
const strip = new THREE.Mesh(stripGeo, stripMat);
if (Math.abs(dx) > Math.abs(dy)) {
strip.position.set(dx > 0 ? w/2 + 0.003 : -w/2 - 0.003, midH, 0);
strip.rotation.y = Math.PI/2;
} else {
strip.position.set(0, midH, dy < 0 ? -d/2 - 0.003 : d/2 + 0.003);
}
group.add(strip);
}
}
// CSS2D Label
const labelEl = document.createElement('div');
labelEl.className = 'device-label';
labelEl.textContent = def.name;
labelEl.style.borderColor = def.hexStr + '66';
const labelObj = new CSS2DObject(labelEl);
labelObj.position.set(0, h + 0.15, 0);
group.add(labelObj);
deviceLabels[uuid] = labelObj;
return group;
}
function addDeviceToScene(deviceId, uuid) {
if (deviceMeshes[uuid]) return;
const mesh = createDeviceMesh(deviceId, uuid);
if (!mesh) return;
mesh.position.set(0, -0.5, 0);
mesh.scale.set(0.01, 0.01, 0.01);
deviceGroup.add(mesh);
deviceMeshes[uuid] = mesh;
}
function removeDeviceFromScene(uuid) {
if (!deviceMeshes[uuid]) return;
deviceAnims[uuid] = { targetX: 0, targetZ: 0, scaleDown: true };
setTimeout(() => {
if (deviceMeshes[uuid]) {
deviceGroup.remove(deviceMeshes[uuid]);
delete deviceMeshes[uuid];
delete deviceAnims[uuid];
}
}, 350);
}
/* ── Dynamic Device List Rendering ─────────── */
let _allDeviceKeys = [];
function renderDeviceCards() {
const listEl = document.getElementById('device-list');
listEl.innerHTML = '';
const filter = (document.getElementById('device-search')?.value || '').toLowerCase();
const devicesOnly = document.getElementById('filter-devices-only')?.checked ?? true;
const keys = Object.keys(CATALOG);
_allDeviceKeys = keys;
// Filter: search + consumable toggle
const filtered = keys.filter(k => {
const def = CATALOG[k];
if (devicesOnly && def.isConsumable) return false;
if (!filter) return true;
// Search across id, name, and words split by underscore/space
const haystack = (k + ' ' + def.name).toLowerCase();
// Support multi-word search: all terms must match
const terms = filter.split(/\s+/);
return terms.every(t => haystack.includes(t));
});
// Show count
const badge = document.getElementById('device-count');
const selCount = state.selected.length;
badge.textContent = `${selCount} sel / ${filtered.length} shown`;
for (const key of filtered) {
const def = CATALOG[key];
const [w, d] = def.size;
const wCM = Math.round(w * 100), dCM = Math.round(d * 100);
const row = document.createElement('div');
row.className = 'device-row';
row.onclick = () => window.addDevice(key);
row.innerHTML = `
<span class="dr-name" title="${def.name} (${key})">${def.name}</span>
<span class="dr-size">${wCM}x${dCM}cm</span>
<span class="dr-add">+</span>
`;
listEl.appendChild(row);
}
}
/* ── Device Selection (支持同一设备多次添加) ── */
window.addDevice = function(deviceId) {
const uuid = genUUID();
state.selected.push({ device_id: deviceId, uuid });
addDeviceToScene(deviceId, uuid);
updateUI();
};
window.removeDevice = function(uuid) {
const idx = state.selected.findIndex(s => s.uuid === uuid);
if (idx >= 0) state.selected.splice(idx, 1);
removeDeviceFromScene(uuid);
delete state.placements[uuid];
updateUI();
};
window.clearAll = function() {
for (const s of [...state.selected]) removeDeviceFromScene(s.uuid);
state.selected = [];
state.placements = {};
state.placed = false;
updateUI();
showToast('🗑', 'Lab cleared');
};
/* ── Layout Algorithms ──────────────────────── */
function jsBinPack(selectedItems, labW, labD, margin) {
// selectedItems: [{device_id, uuid}, ...] — 从 state.selected 传入
const items = selectedItems.map(s => ({
...s,
size: CATALOG[s.device_id]?.size || [0.6, 0.4],
}));
const sorted = [...items].sort((a, b) => b.size[0]*b.size[1] - a.size[0]*a.size[1]);
const placements = [];
let rowX = margin, rowY = margin, rowH = 0;
for (const item of sorted) {
const [w, d] = item.size;
if (rowX + w + margin > labW) {
rowX = margin;
rowY += rowH + margin;
rowH = 0;
}
if (rowY + d + margin > labD) {
placements.push({ device_id: item.device_id, uuid: item.uuid, position: {x: labW/2, y: labD/2, z: 0}, rotation: {x: 0, y: 0, z: 0} });
continue;
}
placements.push({ device_id: item.device_id, uuid: item.uuid, position: {x: rowX + w/2, y: rowY + d/2, z: 0}, rotation: {x: 0, y: 0, z: 0} });
rowX += w + margin;
rowH = Math.max(rowH, d);
}
return placements;
}
async function callBackend(selectedItems, labW, labD, margin) {
const seeder = document.getElementById('seeder-select').value;
const runDE = document.getElementById('run-de-checkbox').checked;
const orientationWeight = parseFloat(document.getElementById('orientation-weight').value) || 5;
const alignWeight = parseFloat(document.getElementById('align-weight').value) || 2;
const body = {
devices: selectedItems.map(s => {
const def = CATALOG[s.device_id];
return {
id: s.device_id,
name: def?.name || s.device_id,
size: def?.size || [0.6, 0.4],
device_type: 'static',
uuid: s.uuid,
};
}),
lab: { width: labW, depth: labD, obstacles: [] },
constraints: [{ type: 'hard', rule_name: 'min_spacing', params: { min_gap: margin } }],
seeder: seeder,
seeder_overrides: { orientation_weight: orientationWeight, align_weight: alignWeight },
run_de: runDE,
maxiter: 300,
};
const resp = await fetch('/optimize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
/* ── Run Layout ─────────────────────────────── */
window.runLayout = async function() {
if (state.selected.length === 0) {
showToast('', 'Select at least one device first');
return;
}
const labW = parseFloat(document.getElementById('lab-w').value) || 4;
const labD = parseFloat(document.getElementById('lab-d').value) || 4;
const margin = (parseFloat(document.getElementById('margin').value) || 50) / 1000; // mm→m
const algo = document.getElementById('algorithm').value;
setRunning(true);
setStatus('Computing layout…', 'warn');
let placements = null;
let usedBackend = false;
if (algo === 'backend') {
try {
const res = await callBackend(state.selected, labW, labD, margin);
placements = res.placements;
usedBackend = true;
const seederInfo = `Preset: ${res.seeder_used || '?'} | DE: ${res.de_ran ? 'Yes' : 'No'}`;
showResult(res.cost, res.success, seederInfo);
} catch (err) {
console.warn('Backend not available, falling back to JS:', err);
showToast('⚠', 'Backend unavailable — using local algorithm');
placements = jsBinPack(state.selected, labW, labD, margin);
showResultInfo('Local bin-packing');
}
} else {
placements = jsBinPack(state.selected, labW, labD, margin);
showResultInfo('Local bin-packing');
}
// Apply placements — match by uuid
for (const p of placements) {
const uuid = p.uuid || p.device_id;
const entry = state.selected.find(s => s.uuid === uuid);
if (!entry) continue;
state.placements[uuid] = p;
// Convert: optimizer center-based (0→labW, 0→labD) → Three.js XZ centered at origin
const px = p.position?.x ?? p.x ?? 0;
const py = p.position?.y ?? p.y ?? 0;
const theta = p.rotation?.z ?? p.theta ?? 0;
const tx = px - labW / 2;
const tz = py - labD / 2;
animatePlacement(uuid, tx, tz, theta);
}
state.placed = true;
updateUI();
setRunning(false);
setStatus('Layout applied', 'ok');
if (usedBackend) showToast('✓', 'Differential Evolution layout complete');
else showToast('✓', 'Layout placed');
};
/* ── Placement Animation ────────────────────── */
function animatePlacement(uuid, tx, tz, theta) {
deviceAnims[uuid] = { targetX: tx, targetZ: tz, targetTheta: theta, active: true };
}
/* ── Render Loop ────────────────────────────── */
const LERP_SPEED = 0.08;
function animate() {
requestAnimationFrame(animate);
controls.update();
// Animate device positions
for (const [key, anim] of Object.entries(deviceAnims)) {
const mesh = deviceMeshes[key];
if (!mesh) continue;
if (anim.scaleDown) {
mesh.scale.lerp(new THREE.Vector3(0.01, 0.01, 0.01), 0.15);
mesh.position.y = THREE.MathUtils.lerp(mesh.position.y, -0.5, 0.1);
continue;
}
if (!anim.active) continue;
// Scale in
mesh.scale.lerp(new THREE.Vector3(1, 1, 1), LERP_SPEED + 0.04);
// Move to position
mesh.position.x = THREE.MathUtils.lerp(mesh.position.x, anim.targetX, LERP_SPEED);
mesh.position.y = THREE.MathUtils.lerp(mesh.position.y, 0, LERP_SPEED);
mesh.position.z = THREE.MathUtils.lerp(mesh.position.z, anim.targetZ, LERP_SPEED);
// Rotate
const targetY = -anim.targetTheta; // three.js Y-rotation = -theta
mesh.rotation.y = THREE.MathUtils.lerp(mesh.rotation.y, targetY, LERP_SPEED);
}
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
}
animate();
/* ── Resize ─────────────────────────────────── */
function onResize() {
const w = container.clientWidth, h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
labelRenderer.setSize(w, h);
}
window.addEventListener('resize', onResize);
onResize();
/* ── Camera Controls ────────────────────────── */
window.resetCamera = function() {
controls.target.set(0, 0, 0);
camera.position.set(3.5, 3.5, 4.5);
controls.update();
};
window.zoomFit = function() {
const w = parseFloat(document.getElementById('lab-w').value) || 4;
const d = parseFloat(document.getElementById('lab-d').value) || 4;
const dist = Math.max(w, d) * 1.1;
camera.position.set(dist, dist, dist);
controls.target.set(0, 0, 0);
controls.update();
};
let gridVisible = true;
window.toggleGrid = function() {
gridVisible = !gridVisible;
if (gridHelper) gridHelper.visible = gridVisible;
document.getElementById('toggle-grid').style.color = gridVisible ? '' : '#6E7681';
};
window.setView = function(mode) {
document.getElementById('tab-3d').classList.toggle('active', mode === '3d');
document.getElementById('tab-top').classList.toggle('active', mode === 'top');
if (mode === 'top') {
camera.position.set(0, 8, 0);
controls.target.set(0, 0, 0);
camera.up.set(0, 0, -1);
} else {
camera.up.set(0, 1, 0);
window.resetCamera();
}
controls.update();
};
// Mousemove → coords
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const labW = parseFloat(document.getElementById('lab-w').value) || 4;
const labD = parseFloat(document.getElementById('lab-d').value) || 4;
const x = ((e.clientX - rect.left) / rect.width - 0.5) * labW;
const z = ((e.clientY - rect.top) / rect.height - 0.5) * labD;
document.getElementById('sb-coords').textContent = `x: ${x.toFixed(2)} m z: ${z.toFixed(2)} m`;
});
/* ── UI Updates ─────────────────────────────── */
function updateUI() {
const count = state.selected.length;
renderDeviceCards(); // refresh count badge
document.getElementById('sb-devices').textContent = `Devices: ${count}`;
// Empty state
document.getElementById('empty-state').classList.toggle('hidden', count > 0);
// Selected list in right panel
const listEl = document.getElementById('selected-list');
const emptyEl = document.getElementById('selected-empty');
emptyEl.style.display = count ? 'none' : 'block';
listEl.querySelectorAll('.selected-item').forEach(el => el.remove());
for (const s of state.selected) {
const def = CATALOG[s.device_id];
if (!def) continue;
const p = state.placements[s.uuid];
let posText = 'Pending placement';
if (p) {
const px = p.position?.x ?? p.x ?? 0;
const py = p.position?.y ?? p.y ?? 0;
posText = `x:${px.toFixed(2)} y:${py.toFixed(2)}`;
}
const el = document.createElement('div');
el.className = 'selected-item';
el.innerHTML = `
<div class="sel-icon" style="background:${def.hexStr}22">
<svg viewBox="0 0 24 24" fill="none" stroke="${def.hexStr}" stroke-width="2" style="width:14px;height:14px"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/></svg>
</div>
<div class="sel-info">
<div class="sel-name">${def.name}</div>
<div class="sel-pos">${posText}</div>
</div>
<div class="sel-del" onclick="removeDevice('${s.uuid}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</div>
`;
listEl.appendChild(el);
}
}
function setRunning(running) {
const btn = document.getElementById('run-btn');
const tbBtn = document.getElementById('btn-autolayout');
btn.disabled = running;
tbBtn.disabled = running;
if (running) {
btn.classList.add('running');
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> Computing…`;
} else {
btn.classList.remove('running');
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg> Run Auto Layout`;
}
}
function setStatus(text, kind) {
document.getElementById('sb-status').textContent = text;
document.getElementById('sb-dot').className = `sb-dot${kind === 'warn' ? ' warn' : ''}`;
document.getElementById('sb-status').style.color = kind === 'ok' ? '#3FB950' : kind === 'warn' ? '#F0A84E' : '#8B949E';
}
function showResult(cost, success, seederInfo) {
const el = document.getElementById('result-badge');
el.style.display = 'flex';
el.className = `cost-badge ${success ? 'success' : 'fail'}`;
const extra = seederInfo ? ` | ${seederInfo}` : '';
el.innerHTML = success
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> Cost: ${cost.toFixed(4)} — Collision-free${extra}`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> Cost: ${cost.toFixed(4)} — Constraints unsatisfied${extra}`;
}
function showResultInfo(algo) {
const el = document.getElementById('result-badge');
el.style.display = 'flex';
el.className = 'cost-badge info';
el.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> ${algo}`;
}
/* ── Toast ──────────────────────────────────── */
let toastTimer = null;
window.showToast = function(icon, msg) {
const el = document.getElementById('toast');
el.innerHTML = `<span>${icon}</span> ${msg}`;
el.classList.add('show');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 2800);
};
// 搜索和过滤事件绑定module 作用域内,不能用 inline handler
document.getElementById('device-search').addEventListener('input', () => renderDeviceCards());
document.getElementById('device-search').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Enter 键添加第一个匹配设备
const filter = e.target.value.toLowerCase();
if (!filter) return;
const devicesOnly = document.getElementById('filter-devices-only')?.checked ?? true;
const match = Object.keys(CATALOG).find(k => {
const def = CATALOG[k];
if (devicesOnly && def.isConsumable) return false;
const haystack = (k + ' ' + def.name).toLowerCase();
const terms = filter.split(/\s+/);
return terms.every(t => haystack.includes(t));
});
if (match) {
window.addDevice(match);
showToast('+', `Added ${CATALOG[match].name}`);
}
}
});
document.getElementById('filter-devices-only').addEventListener('change', () => renderDeviceCards());
// 初始化:加载设备目录 → 渲染卡片 → 更新 UI
loadDeviceCatalog().then(() => {
updateUI();
setStatus('Ready', 'ok');
});
/* ── Scene Poller (demo agent) ─────────────── */
let _scenePollVersion = -1; // -1 = uninitialized, first poll sets baseline
setInterval(async () => {
try {
const res = await fetch('/scene/placements');
if (!res.ok) return;
const data = await res.json();
if (_scenePollVersion === -1) { _scenePollVersion = data.version; return; } // baseline on first poll
if (data.version <= _scenePollVersion) return;
_scenePollVersion = data.version;
const labW = parseFloat(document.getElementById('lab-w').value) || 4;
const labD = parseFloat(document.getElementById('lab-d').value) || 4;
for (const p of data.placements) {
const did = p.device_id;
const entry = state.selected.find(s => s.device_id === did || s.uuid === did);
if (!entry) continue;
const px = p.position?.x ?? 0;
const py = p.position?.y ?? 0;
const theta = p.rotation?.z ?? 0;
animatePlacement(entry.uuid, px - labW / 2, py - labD / 2, theta);
}
} catch (_) { /* server may not be running */ }
}, 1000);
</script>
</body>
</html>