mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-05-23 03:19:55 +00:00
1228 lines
51 KiB
HTML
1228 lines
51 KiB
HTML
<!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: — 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 = 0;
|
||
setInterval(async () => {
|
||
try {
|
||
const res = await fetch('/scene/placements');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
if (data.version < _scenePollVersion) _scenePollVersion = 0; // server restart
|
||
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>
|