9131 lines
353 KiB
JavaScript
Executable File
9131 lines
353 KiB
JavaScript
Executable File
function createOpaqueId(prefix) {
|
||
if (globalThis.crypto?.randomUUID) {
|
||
return `${prefix}-${globalThis.crypto.randomUUID()}`;
|
||
}
|
||
if (globalThis.crypto?.getRandomValues) {
|
||
const bytes = new Uint8Array(16);
|
||
globalThis.crypto.getRandomValues(bytes);
|
||
const randomHex = [...bytes]
|
||
.map((value) => value.toString(16).padStart(2, "0"))
|
||
.join("");
|
||
return `${prefix}-${randomHex}`;
|
||
}
|
||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
|
||
}
|
||
|
||
function createConversationId() {
|
||
return createOpaqueId("conv");
|
||
}
|
||
|
||
function loadConversationId() {
|
||
const stored = sessionStorage.getItem("cognitive-agent.conversation-id");
|
||
// A browser runtime restart is a new virtual run. Do not reuse the old
|
||
// conversation actuator state; hardware mode will repopulate this fresh
|
||
// context from authoritative CAN feedback when it is selected.
|
||
if (stored?.startsWith("conv-")) {
|
||
sessionStorage.removeItem("cognitive-agent.conversation-id");
|
||
}
|
||
const conversationId = createConversationId();
|
||
sessionStorage.setItem("cognitive-agent.conversation-id", conversationId);
|
||
return conversationId;
|
||
}
|
||
|
||
function loadHardwareClientId() {
|
||
const key = "cognitive-agent.hardware-client-id";
|
||
const stored = localStorage.getItem(key);
|
||
if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
|
||
const clientId = createOpaqueId("hwc");
|
||
localStorage.setItem(key, clientId);
|
||
return clientId;
|
||
}
|
||
|
||
function loadControlTransport() {
|
||
const stored = localStorage.getItem("cognitive-agent.control-transport");
|
||
return ["wired_can", "wireless_mqtt"].includes(stored) ? stored : "wired_can";
|
||
}
|
||
|
||
const DEFAULT_EXECUTION_MODE = "hardware";
|
||
|
||
const state = {
|
||
provider: "openai",
|
||
reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium",
|
||
executionMode: DEFAULT_EXECUTION_MODE,
|
||
preferredExecutionMode: DEFAULT_EXECUTION_MODE,
|
||
controlTransport: loadControlTransport(),
|
||
conversationId: loadConversationId(),
|
||
vehicleId: null,
|
||
runtime: null,
|
||
result: null,
|
||
vehicleStateSnapshot: null,
|
||
vehicleStateTurn: -1,
|
||
seatTwin: null,
|
||
seatActuatorState: {},
|
||
seatFeedbackSource: "simulated",
|
||
seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
|
||
availableSeatAxes: null,
|
||
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
|
||
hardwareClientId: loadHardwareClientId(),
|
||
hardwareAccess: null,
|
||
hardwareAccessPoller: null,
|
||
physicalStatePoller: null,
|
||
physicalStateSyncPromise: null,
|
||
passengerStateSyncPromise: null,
|
||
wiredCanProbe: { passenger: false, vehicle: false },
|
||
feedbackSignals: { passenger: false, vehicle: false },
|
||
pendingModeProfileId: null,
|
||
modeRunTimer: null,
|
||
modeHighlightTimer: null,
|
||
latestModeRefreshKey: "",
|
||
modeEditor: null,
|
||
directCanCommandPending: false,
|
||
};
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||
const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(input, options);
|
||
// A four-axis hardware mode is executed serially. Each absolute move may wait
|
||
// up to 33 seconds, and firmware recovery or known-range homing can extend the
|
||
// request further. Keep the browser deadline above the backend's bounded HIL
|
||
// workflow so the UI does not report failure while the seat is still moving.
|
||
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
||
const REQUEST_TIMEOUT_MS = 45 * 1000;
|
||
const DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS = 5000;
|
||
const AUTHORITATIVE_CH1_SOURCES = new Set(["physical_can_ch1", "wireless_mqtt_ch1"]);
|
||
|
||
async function apiResponseError(response) {
|
||
let payload = null;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (_error) {
|
||
// Some proxies return an empty or non-JSON error page. Preserve the HTTP
|
||
// status even when no structured reason is available.
|
||
}
|
||
const status = Number(response?.status) || 0;
|
||
const serverMessage = String(payload?.message || payload?.error || "").trim();
|
||
const fallback = status ? `HTTP ${status}` : "请求失败";
|
||
const error = new Error(serverMessage ? `${fallback}:${serverMessage}` : fallback);
|
||
error.status = status;
|
||
error.code = String(payload?.error || "").trim();
|
||
error.serverMessage = serverMessage;
|
||
return error;
|
||
}
|
||
|
||
function requestTimeoutForExecutionMode(mode) {
|
||
return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
|
||
}
|
||
|
||
function physicalStatePollIntervalMs() {
|
||
const configured = Number(selectedVehicleTransport()?.state_sync?.poll_interval_ms);
|
||
if (!Number.isFinite(configured)) return DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS;
|
||
return Math.max(250, Math.min(DEFAULT_PHYSICAL_STATE_POLL_INTERVAL_MS, configured));
|
||
}
|
||
|
||
function isAuthoritativeCh1Source(value) {
|
||
return AUTHORITATIVE_CH1_SOURCES.has(String(value || ""));
|
||
}
|
||
|
||
function renderCabinLiveStatus(networkStatus = state.connectionStatus) {
|
||
const indicator = $("#cabinLiveStatus");
|
||
if (!indicator) return;
|
||
const label = $("b", indicator);
|
||
if (!label) return;
|
||
indicator.removeAttribute("aria-label");
|
||
indicator.removeAttribute("title");
|
||
const summarySystem = $("#cabinSummarySystem");
|
||
const setSummarySystem = (text, status) => {
|
||
if (!summarySystem) return;
|
||
summarySystem.textContent = text;
|
||
summarySystem.dataset.state = status;
|
||
};
|
||
if (networkStatus !== "online") {
|
||
indicator.dataset.state = networkStatus === "connecting" ? "connecting" : "offline";
|
||
label.textContent = networkStatus === "connecting"
|
||
? "正在连接车辆服务"
|
||
: "车辆服务通信离线";
|
||
setSummarySystem(networkStatus === "connecting" ? "连接中" : "服务离线", "waiting");
|
||
return;
|
||
}
|
||
|
||
const selected = selectedVehicleTransport();
|
||
const wireless = state.controlTransport === "wireless_mqtt";
|
||
const transportLabel = wireless ? "无线 CAN" : "有线 CAN";
|
||
if (selected?.state_configured !== true) {
|
||
indicator.dataset.state = "offline";
|
||
label.textContent = `${transportLabel} 未配置`;
|
||
setSummarySystem("链路未配置", "waiting");
|
||
return;
|
||
}
|
||
if (!transportConnectionOnline(state.controlTransport, selected)) {
|
||
indicator.dataset.state = "offline";
|
||
label.textContent = `${transportLabel} · 信号链路断开`;
|
||
indicator.title = selected?.state_sync?.last_error || "尚未检测到车辆通信设备";
|
||
setSummarySystem("链路断开", "warning");
|
||
return;
|
||
}
|
||
|
||
const snapshot = state.vehicleStateSnapshot;
|
||
if (snapshot?.errorStatus === 403) {
|
||
const reason = snapshot.errorReason || "当前终端没有车辆状态读取权限";
|
||
const visibleReason = `${transportLabel} · 403:${reason}`;
|
||
indicator.dataset.state = "offline";
|
||
label.textContent = visibleReason;
|
||
indicator.title = visibleReason;
|
||
indicator.setAttribute("aria-label", visibleReason);
|
||
const telemetryState = $("#vehicleTelemetryState");
|
||
if (telemetryState) telemetryState.textContent = "403 拒绝";
|
||
setSummarySystem("403 · 状态读取被拒绝", "warning");
|
||
return;
|
||
}
|
||
const telemetryState = $("#vehicleTelemetryState");
|
||
if (telemetryState?.textContent === "403 拒绝") {
|
||
telemetryState.textContent = "只读";
|
||
}
|
||
const observedIds = Array.isArray(snapshot?.observedFrameIds)
|
||
? snapshot.observedFrameIds
|
||
: [];
|
||
const maxAgeMs = Math.max(3000, physicalStatePollIntervalMs() * 4);
|
||
const responseAgeMs = Date.now() - Number(snapshot?.receivedAt || 0);
|
||
if (!snapshot || responseAgeMs > maxAgeMs || observedIds.length === 0) {
|
||
indicator.dataset.state = "connecting";
|
||
label.textContent = `${transportLabel} · 等待 CAN1 状态`;
|
||
indicator.title = snapshot?.error || "尚未收到可按矩阵解析的 CAN1 状态帧";
|
||
setSummarySystem("等待状态", "waiting");
|
||
return;
|
||
}
|
||
|
||
const sourceLabel = snapshot.source === "wireless_mqtt_ch1" ? "无线 CAN" : "有线 CAN";
|
||
indicator.dataset.state = "online";
|
||
label.textContent = `${sourceLabel} 实时 · ${observedIds.length} 类报文`;
|
||
indicator.title = [
|
||
snapshot.capturedAt ? `采集时间 ${snapshot.capturedAt}` : "",
|
||
observedIds.length ? `报文 ${observedIds.join("、")}` : "",
|
||
].filter(Boolean).join(" · ");
|
||
if (summarySystem?.dataset.state !== "warning") {
|
||
setSummarySystem("CAN 实时", "healthy");
|
||
}
|
||
}
|
||
|
||
function refreshIcons() {
|
||
if (window.lucide) {
|
||
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
}
|
||
|
||
const GLASS_THEME_STORAGE_KEY = "cognitive-agent.glass-theme";
|
||
|
||
function currentGlassTheme() {
|
||
return document.documentElement.dataset.glassTheme === "light" ? "light" : "dark";
|
||
}
|
||
|
||
function applyGlassTheme(theme, { persist = false, announce = true } = {}) {
|
||
const normalized = theme === "light" ? "light" : "dark";
|
||
document.documentElement.dataset.glassTheme = normalized;
|
||
document.documentElement.style.colorScheme = normalized;
|
||
const toggle = $("#glassThemeToggle");
|
||
const nextLabel = normalized === "dark" ? "切换到白日模式" : "切换到夜间模式";
|
||
if (toggle) {
|
||
toggle.dataset.theme = normalized;
|
||
toggle.setAttribute("aria-label", nextLabel);
|
||
toggle.setAttribute("aria-pressed", String(normalized === "dark"));
|
||
toggle.title = nextLabel;
|
||
}
|
||
const themeColor = $("#themeColorMeta");
|
||
if (themeColor) {
|
||
themeColor.setAttribute("content", normalized === "dark" ? "#17182b" : "#eef7ff");
|
||
}
|
||
if (persist) {
|
||
try {
|
||
localStorage.setItem(GLASS_THEME_STORAGE_KEY, normalized);
|
||
} catch (_error) {
|
||
// Keep the in-memory theme active if storage is unavailable.
|
||
}
|
||
try {
|
||
document.cookie = `${GLASS_THEME_STORAGE_KEY}=${normalized}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||
} catch (_error) {
|
||
// Cookie fallback can also be disabled; theme switching remains usable.
|
||
}
|
||
}
|
||
if (announce) {
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:theme-change", {
|
||
detail: { theme: normalized },
|
||
}));
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function initGlassThemeToggle() {
|
||
const toggle = $("#glassThemeToggle");
|
||
if (!toggle || toggle.dataset.themeToggleBound === "true") return;
|
||
toggle.dataset.themeToggleBound = "true";
|
||
applyGlassTheme(currentGlassTheme(), { announce: false });
|
||
toggle.addEventListener("click", () => {
|
||
applyGlassTheme(currentGlassTheme() === "dark" ? "light" : "dark", {
|
||
persist: true,
|
||
});
|
||
});
|
||
}
|
||
|
||
const TWIN_DEFAULT_POSITIONS = {
|
||
longitudinal: 50,
|
||
cushion_tilt: 50,
|
||
height: 50,
|
||
backrest_recline: 20,
|
||
legrest_height: 0,
|
||
legrest_extension: 0,
|
||
};
|
||
const TWIN_ANGLE_RANGES = {
|
||
backrest_recline: [95, 150],
|
||
legrest_height: [95, 167],
|
||
};
|
||
|
||
function initJointTwinLayout() {
|
||
const cabinBand = $(".cabin-band");
|
||
const twinBand = $(".twin-band");
|
||
const stateBand = $(".state-band");
|
||
const passengerCard = $('.seat-card[data-seat="passenger"]');
|
||
const driverCard = $('.seat-card[data-seat="driver"]');
|
||
const domainGrid = $(".domain-grid");
|
||
const coverage = $(".coverage-section");
|
||
if (!cabinBand || !twinBand || !stateBand || !passengerCard || !driverCard || !domainGrid || !coverage) return;
|
||
|
||
const shell = document.createElement("section");
|
||
shell.className = "joint-twin-shell";
|
||
shell.setAttribute("aria-labelledby", "jointTwinTitle");
|
||
const heading = document.createElement("header");
|
||
heading.className = "joint-twin-heading";
|
||
heading.innerHTML = `
|
||
<div>
|
||
<span class="section-kicker">VEHICLE DIGITAL TWIN · JOINT CONTROL</span>
|
||
<h3 id="jointTwinTitle">整车联合孪生总览</h3>
|
||
<p>双通道前排座椅孪生:CH0 对应副驾座椅,CH1 对应主驾座椅;两侧状态相互独立。</p>
|
||
</div>
|
||
<div class="joint-scope" aria-label="联合控制范围">
|
||
<span><i data-lucide="armchair"></i>副驾座椅</span>
|
||
<i data-lucide="plus"></i>
|
||
<span><i data-lucide="armchair"></i>主驾座椅</span>
|
||
<i data-lucide="plus"></i>
|
||
<span><i data-lucide="fan"></i>空调</span>
|
||
<i data-lucide="plus"></i>
|
||
<span><i data-lucide="lamp-ceiling"></i>氛围灯</span>
|
||
<i data-lucide="plus"></i>
|
||
<span><i data-lucide="panels-top-left"></i>天幕</span>
|
||
</div>`;
|
||
|
||
cabinBand.before(shell);
|
||
shell.append(heading, twinBand, cabinBand);
|
||
[passengerCard, driverCard].forEach((card) => card.classList.add("joint-seat-panel", "domain-panel"));
|
||
passengerCard.dataset.deviceChannel = "ch0";
|
||
driverCard.dataset.deviceChannel = "ch1";
|
||
const seatFeedback = $("#seatFeedbackModeLabel");
|
||
if (seatFeedback) coverage.prepend(seatFeedback);
|
||
domainGrid.prepend(driverCard);
|
||
domainGrid.prepend(passengerCard);
|
||
|
||
const vehicleStatusPanel = $('[data-domain-panel="vehicle_status"]', domainGrid);
|
||
if (vehicleStatusPanel) {
|
||
vehicleStatusPanel.classList.add("cabin-telemetry-source");
|
||
vehicleStatusPanel.hidden = true;
|
||
cabinBand.append(vehicleStatusPanel);
|
||
}
|
||
|
||
cabinBand.classList.add("cabin-compact-board");
|
||
const cabinHeading = $(".cabin-heading", cabinBand);
|
||
const cabinHeadingActions = document.createElement("div");
|
||
cabinHeadingActions.className = "cabin-heading-actions";
|
||
cabinHeadingActions.innerHTML = `
|
||
<span class="cabin-live-status" id="cabinLiveStatus"><i></i><b>正在读取车辆 CAN 状态</b></span>
|
||
<button class="cabin-expand-button" id="cabinExpandButton" type="button" aria-expanded="false">
|
||
<i data-lucide="maximize-2"></i><span>展开查看</span>
|
||
</button>`;
|
||
cabinHeading?.append(cabinHeadingActions);
|
||
|
||
const cabinToolbar = document.createElement("div");
|
||
cabinToolbar.className = "cabin-board-toolbar";
|
||
cabinToolbar.innerHTML = `
|
||
<dl class="cabin-vehicle-summary" aria-label="车辆实时概况">
|
||
<div><dt>当前档位</dt><dd id="cabinSummaryGear">--</dd></div>
|
||
<div><dt>实时车速</dt><dd id="cabinSummarySpeed">-- km/h</dd></div>
|
||
<div><dt>门锁状态</dt><dd id="cabinSummaryDoors">状态未知</dd></div>
|
||
<div><dt>系统状态</dt><dd id="cabinSummarySystem">等待状态</dd></div>
|
||
</dl>
|
||
<div class="cabin-carousel-controls" aria-label="设备卡片浏览">
|
||
<span class="cabin-carousel-count" aria-live="polite"><b id="cabinCarouselRange">1–3</b><i> / 7</i></span>
|
||
<div role="group" aria-label="左右浏览设备卡片">
|
||
<button id="cabinCarouselPrev" type="button" aria-label="查看上一组设备" disabled>
|
||
<i data-lucide="chevron-left"></i>
|
||
</button>
|
||
<button id="cabinCarouselNext" type="button" aria-label="查看下一组设备">
|
||
<i data-lucide="chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
cabinBand.insertBefore(cabinToolbar, domainGrid);
|
||
|
||
const powerEntryDevices = [
|
||
{
|
||
card: passengerCard,
|
||
key: "passenger_seat",
|
||
name: "副驾座椅",
|
||
measurable: true,
|
||
components: [
|
||
["longitudinal_motor", "水平电机"],
|
||
["cushion_motor", "坐盆电机"],
|
||
["backrest_motor", "座椅靠背电机"],
|
||
["legrest_motor", "腿托电机"],
|
||
["ventilation_control", "通风控制"],
|
||
["heating_control", "加热控制"],
|
||
],
|
||
},
|
||
{ card: driverCard, key: "driver_seat", name: "主驾座椅", measurable: true, components: [] },
|
||
{
|
||
card: $('[data-domain-panel="climate"]', domainGrid),
|
||
key: "climate",
|
||
name: "空调",
|
||
measurable: true,
|
||
components: [
|
||
["compressor", "压缩机"],
|
||
["electronic_fan", "电子风扇"],
|
||
["blower", "鼓风机"],
|
||
["thermal_management_module", "空调热管理集成模块"],
|
||
["compressor_controller", "压缩机电控"],
|
||
["electronic_cooling_pump", "电控冷却水泵"],
|
||
],
|
||
},
|
||
{ card: $('[data-domain-panel="roof"]', domainGrid), key: "roof", name: "遮阳帘", measurable: true, components: [] },
|
||
{ card: $('[data-domain-panel="ambient_light"]', domainGrid), key: "ambient_light", name: "车内灯光", measurable: false, components: [] },
|
||
{
|
||
card: $('[data-domain-panel="window"]', domainGrid),
|
||
key: "window",
|
||
name: "车窗",
|
||
measurable: true,
|
||
components: [
|
||
["front_left_window", "左前车窗"],
|
||
["front_right_window", "右前车窗"],
|
||
["rear_left_window", "左后车窗"],
|
||
["rear_right_window", "右后车窗"],
|
||
],
|
||
},
|
||
{
|
||
card: $('[data-domain-panel="front_light"]', domainGrid),
|
||
key: "front_light",
|
||
name: "前大灯",
|
||
measurable: true,
|
||
components: [
|
||
["front_left_combination_lamp", "左前组合灯"],
|
||
["front_right_combination_lamp", "右前组合灯"],
|
||
],
|
||
},
|
||
];
|
||
const powerDeviceByKey = new Map(powerEntryDevices.map((device) => [device.key, device]));
|
||
const powerTelemetryByDevice = new Map();
|
||
let activePowerDevice = null;
|
||
let activePowerTrendRange = "trip";
|
||
let powerDrawerReturnFocus = null;
|
||
|
||
const powerBackdrop = document.createElement("div");
|
||
powerBackdrop.className = "device-power-backdrop";
|
||
powerBackdrop.id = "devicePowerBackdrop";
|
||
powerBackdrop.hidden = true;
|
||
const powerDrawer = document.createElement("aside");
|
||
powerDrawer.className = "device-power-drawer";
|
||
powerDrawer.id = "devicePowerDrawer";
|
||
powerDrawer.hidden = true;
|
||
powerDrawer.setAttribute("role", "dialog");
|
||
powerDrawer.setAttribute("aria-modal", "true");
|
||
powerDrawer.setAttribute("aria-labelledby", "devicePowerTitle");
|
||
powerDrawer.innerHTML = `
|
||
<header class="device-power-drawer-heading">
|
||
<div>
|
||
<span class="section-kicker">DEVICE POWER INSIGHT</span>
|
||
<h2 id="devicePowerTitle">设备功率详情</h2>
|
||
<p><i></i><span id="devicePowerFreshness">实时更新 · 等待功率数据</span></p>
|
||
</div>
|
||
<button id="devicePowerClose" type="button" aria-label="关闭功率详情"><i data-lucide="x"></i></button>
|
||
</header>
|
||
<div class="device-power-drawer-body">
|
||
<section class="device-power-overview" aria-label="设备功率摘要">
|
||
<div class="device-power-total">
|
||
<small>当前总功率</small>
|
||
<strong><b id="devicePowerCurrent">--</b><span id="devicePowerCurrentUnit">W</span></strong>
|
||
<em id="devicePowerDataState">等待测量数据</em>
|
||
</div>
|
||
<dl class="device-power-energy-summary">
|
||
<div><dt>本次行程</dt><dd id="devicePowerTripEnergy">-- kWh</dd></div>
|
||
<div><dt>今日累计</dt><dd id="devicePowerTodayEnergy">-- kWh</dd></div>
|
||
</dl>
|
||
</section>
|
||
<section class="device-power-components" aria-labelledby="devicePowerComponentsTitle">
|
||
<header>
|
||
<div><span class="section-kicker">POWER BREAKDOWN</span><h3 id="devicePowerComponentsTitle">子部件当前功率</h3></div>
|
||
<span id="devicePowerComponentTotal">合计 -- W</span>
|
||
</header>
|
||
<div class="device-power-component-list" id="devicePowerComponentList"></div>
|
||
</section>
|
||
<section class="device-power-trend" aria-labelledby="devicePowerTrendTitle">
|
||
<header>
|
||
<div><span class="section-kicker">POWER TREND</span><h3 id="devicePowerTrendTitle">功率趋势</h3></div>
|
||
<div class="device-power-trend-tabs" role="group" aria-label="功率趋势时间范围">
|
||
<button type="button" class="active" data-power-trend-range="trip">本次行程</button>
|
||
<button type="button" data-power-trend-range="today">今日</button>
|
||
<button type="button" data-power-trend-range="week">近7天</button>
|
||
</div>
|
||
</header>
|
||
<div class="device-power-chart" id="devicePowerChart">
|
||
<svg viewBox="0 0 520 180" preserveAspectRatio="none" aria-hidden="true">
|
||
<g class="device-power-chart-grid">
|
||
<line x1="36" y1="24" x2="504" y2="24"></line>
|
||
<line x1="36" y1="74" x2="504" y2="74"></line>
|
||
<line x1="36" y1="124" x2="504" y2="124"></line>
|
||
<line x1="36" y1="160" x2="504" y2="160"></line>
|
||
</g>
|
||
<path class="device-power-chart-area" id="devicePowerChartArea" d=""></path>
|
||
<polyline class="device-power-chart-line" id="devicePowerChartLine" points=""></polyline>
|
||
</svg>
|
||
<span class="device-power-chart-y top" id="devicePowerChartMax">-- W</span>
|
||
<span class="device-power-chart-y bottom">0</span>
|
||
<div class="device-power-chart-empty" id="devicePowerChartEmpty"><i data-lucide="chart-no-axes-column-increasing"></i><span>暂无功率趋势数据</span></div>
|
||
<div class="device-power-chart-axis"><span id="devicePowerChartStart">行程开始</span><span id="devicePowerChartEnd">当前</span></div>
|
||
</div>
|
||
</section>
|
||
<footer class="device-power-note"><i data-lucide="info"></i><span id="devicePowerNote">总功率为设备级实时测量值。</span></footer>
|
||
</div>`;
|
||
document.body.append(powerBackdrop, powerDrawer);
|
||
|
||
const finitePowerNumber = (value) => {
|
||
if (value === null || value === undefined || value === "") return null;
|
||
const number = Number(value);
|
||
return Number.isFinite(number) && number >= 0 ? number : null;
|
||
};
|
||
|
||
const formatPower = (value) => {
|
||
const watts = finitePowerNumber(value);
|
||
if (watts === null) return "-- W";
|
||
if (watts >= 1000) return `${(watts / 1000).toFixed(watts >= 10000 ? 1 : 2)} kW`;
|
||
return `${Math.round(watts)} W`;
|
||
};
|
||
|
||
const formatEnergy = (value) => {
|
||
const energy = finitePowerNumber(value);
|
||
return energy === null ? "-- kWh" : `${energy.toFixed(2)} kWh`;
|
||
};
|
||
|
||
const componentPower = (telemetry, key) => {
|
||
const value = telemetry?.components?.[key];
|
||
return finitePowerNumber(value?.power_w ?? value);
|
||
};
|
||
|
||
const historyForRange = (telemetry, range) => {
|
||
const source = Array.isArray(telemetry?.history)
|
||
? telemetry.history
|
||
: telemetry?.history?.[range];
|
||
if (!Array.isArray(source)) return [];
|
||
return source
|
||
.map((item) => finitePowerNumber(item?.power_w ?? item?.value ?? item))
|
||
.filter((item) => item !== null);
|
||
};
|
||
|
||
const renderPowerChart = (telemetry) => {
|
||
const history = historyForRange(telemetry, activePowerTrendRange);
|
||
const empty = $("#devicePowerChartEmpty", powerDrawer);
|
||
const line = $("#devicePowerChartLine", powerDrawer);
|
||
const area = $("#devicePowerChartArea", powerDrawer);
|
||
const maxLabel = $("#devicePowerChartMax", powerDrawer);
|
||
const labels = {
|
||
trip: ["行程开始", "当前"],
|
||
today: ["00:00", "当前"],
|
||
week: ["7天前", "今日"],
|
||
};
|
||
$("#devicePowerChartStart", powerDrawer).textContent = labels[activePowerTrendRange][0];
|
||
$("#devicePowerChartEnd", powerDrawer).textContent = labels[activePowerTrendRange][1];
|
||
if (history.length < 2) {
|
||
line.setAttribute("points", "");
|
||
area.setAttribute("d", "");
|
||
maxLabel.textContent = "-- W";
|
||
empty.hidden = false;
|
||
return;
|
||
}
|
||
const maxPower = Math.max(...history, 1);
|
||
const points = history.map((value, index) => {
|
||
const x = 36 + (468 * index) / (history.length - 1);
|
||
const y = 160 - (136 * value) / maxPower;
|
||
return [x, y];
|
||
});
|
||
line.setAttribute("points", points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "));
|
||
area.setAttribute("d", `M ${points[0][0]} 160 L ${points.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(" L ")} L ${points.at(-1)[0]} 160 Z`);
|
||
maxLabel.textContent = formatPower(maxPower);
|
||
empty.hidden = true;
|
||
};
|
||
|
||
const renderPowerDrawer = () => {
|
||
if (!activePowerDevice) return;
|
||
const telemetry = powerTelemetryByDevice.get(activePowerDevice.key) || {};
|
||
const totalPower = finitePowerNumber(telemetry.power_w ?? telemetry.total_power_w);
|
||
const title = $("#devicePowerTitle", powerDrawer);
|
||
title.textContent = `${activePowerDevice.name}功率详情`;
|
||
const current = $("#devicePowerCurrent", powerDrawer);
|
||
const currentUnit = $("#devicePowerCurrentUnit", powerDrawer);
|
||
if (totalPower !== null && totalPower >= 1000) {
|
||
current.textContent = (totalPower / 1000).toFixed(totalPower >= 10000 ? 1 : 2);
|
||
currentUnit.textContent = "kW";
|
||
} else {
|
||
current.textContent = totalPower === null ? "--" : String(Math.round(totalPower));
|
||
currentUnit.textContent = "W";
|
||
}
|
||
$("#devicePowerTripEnergy", powerDrawer).textContent = formatEnergy(telemetry.trip_energy_kwh);
|
||
$("#devicePowerTodayEnergy", powerDrawer).textContent = formatEnergy(telemetry.today_energy_kwh);
|
||
$("#devicePowerDataState", powerDrawer).textContent = totalPower === null ? "等待测量数据" : "设备总功率";
|
||
const updatedAt = telemetry.updated_at ? new Date(telemetry.updated_at) : null;
|
||
const validUpdatedAt = updatedAt && !Number.isNaN(updatedAt.getTime());
|
||
$("#devicePowerFreshness", powerDrawer).textContent = validUpdatedAt
|
||
? `实时更新 · 最近更新 ${updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}`
|
||
: "实时更新 · 等待功率数据";
|
||
|
||
const componentList = $("#devicePowerComponentList", powerDrawer);
|
||
componentList.replaceChildren();
|
||
const componentValues = activePowerDevice.components
|
||
.map(([key, label]) => ({ key, label, value: componentPower(telemetry, key) }));
|
||
const availableValues = componentValues.map((item) => item.value).filter((value) => value !== null);
|
||
const maxComponentPower = Math.max(...availableValues, 1);
|
||
if (!componentValues.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "device-power-components-empty";
|
||
empty.innerHTML = `
|
||
<span><i data-lucide="gauge"></i></span>
|
||
<div><strong>仅支持设备总功率</strong><p>${activePowerDevice.name}暂无可读的子部件功率通道,顶部总功率仍可独立查看。</p></div>`;
|
||
componentList.append(empty);
|
||
$("#devicePowerComponentTotal", powerDrawer).textContent = "设备级测量";
|
||
} else {
|
||
componentValues.forEach(({ label, value }) => {
|
||
const row = document.createElement("div");
|
||
row.className = "device-power-component-row";
|
||
row.innerHTML = `
|
||
<span>${label}</span>
|
||
<i><b style="--power-share: ${value === null ? 0 : (value / maxComponentPower) * 100}%"></b></i>
|
||
<strong>${formatPower(value)}</strong>`;
|
||
componentList.append(row);
|
||
});
|
||
const componentTotal = availableValues.length
|
||
? availableValues.reduce((sum, value) => sum + value, 0)
|
||
: null;
|
||
$("#devicePowerComponentTotal", powerDrawer).textContent = `合计 ${formatPower(componentTotal)}`;
|
||
}
|
||
$("#devicePowerNote", powerDrawer).textContent = componentValues.length
|
||
? "总功率为设备级实时测量值;子部件功率由各测量通道独立读取。"
|
||
: "该设备仅提供设备级总功率,暂无子部件功率测量通道。";
|
||
renderPowerChart(telemetry);
|
||
refreshIcons();
|
||
};
|
||
|
||
const closePowerDrawer = () => {
|
||
if (powerDrawer.hidden) return;
|
||
powerDrawer.hidden = true;
|
||
powerBackdrop.hidden = true;
|
||
document.body.classList.remove("device-power-drawer-open");
|
||
powerDrawerReturnFocus?.focus();
|
||
powerDrawerReturnFocus = null;
|
||
};
|
||
|
||
const openPowerDrawer = (device, trigger) => {
|
||
activePowerDevice = device;
|
||
activePowerTrendRange = "trip";
|
||
powerDrawerReturnFocus = trigger;
|
||
$$('[data-power-trend-range]', powerDrawer).forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.powerTrendRange === activePowerTrendRange);
|
||
});
|
||
renderPowerDrawer();
|
||
powerBackdrop.hidden = false;
|
||
powerDrawer.hidden = false;
|
||
document.body.classList.add("device-power-drawer-open");
|
||
$("#devicePowerClose", powerDrawer)?.focus();
|
||
};
|
||
|
||
$("#devicePowerClose", powerDrawer)?.addEventListener("click", closePowerDrawer);
|
||
powerBackdrop.addEventListener("click", closePowerDrawer);
|
||
$$('[data-power-trend-range]', powerDrawer).forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
activePowerTrendRange = button.dataset.powerTrendRange;
|
||
$$('[data-power-trend-range]', powerDrawer).forEach((tab) => {
|
||
tab.classList.toggle("active", tab === button);
|
||
});
|
||
renderPowerChart(powerTelemetryByDevice.get(activePowerDevice?.key) || {});
|
||
});
|
||
});
|
||
|
||
const updatePowerTelemetry = (payload) => {
|
||
const updates = payload?.devices && typeof payload.devices === "object"
|
||
? Object.entries(payload.devices)
|
||
: payload?.device ? [[payload.device, payload]] : [];
|
||
updates.forEach(([key, telemetry]) => {
|
||
if (!powerDeviceByKey.has(key) || !telemetry) return;
|
||
powerTelemetryByDevice.set(key, telemetry);
|
||
const cardValue = $(`[data-power-device="${key}"] [data-device-power-value]`, domainGrid);
|
||
if (cardValue) cardValue.textContent = formatPower(telemetry.power_w ?? telemetry.total_power_w);
|
||
});
|
||
if (activePowerDevice && !powerDrawer.hidden) renderPowerDrawer();
|
||
};
|
||
window.addEventListener("cognitive-agent:power-telemetry", (event) => {
|
||
updatePowerTelemetry(event.detail || {});
|
||
});
|
||
window.updateDevicePowerTelemetry = updatePowerTelemetry;
|
||
|
||
powerEntryDevices.forEach((device) => {
|
||
const { card, key, name, measurable } = device;
|
||
if (!card) return;
|
||
card.classList.add("has-device-power-entry");
|
||
const entry = document.createElement(measurable ? "button" : "div");
|
||
entry.className = `device-power-entry${measurable ? "" : " unavailable"}`;
|
||
entry.dataset.powerDevice = key;
|
||
if (measurable) {
|
||
entry.type = "button";
|
||
entry.setAttribute("aria-label", `查看${name}功率详情`);
|
||
entry.innerHTML = `
|
||
<span class="device-power-copy"><i data-lucide="zap"></i><span><small>实时功率</small><b data-device-power-value>-- W</b></span></span>
|
||
<span class="device-power-action">详情<i data-lucide="chevron-right"></i></span>`;
|
||
entry.addEventListener("click", () => openPowerDrawer(device, entry));
|
||
} else {
|
||
entry.setAttribute("aria-label", `${name}暂不支持功率测量`);
|
||
entry.innerHTML = `
|
||
<span class="device-power-copy"><i data-lucide="zap-off"></i><span><small>功率测量</small><b>暂不支持</b></span></span>
|
||
<span class="device-power-unavailable">无测量通道</span>`;
|
||
}
|
||
card.append(entry);
|
||
});
|
||
|
||
const cabinCards = $$(":scope > .domain-panel", domainGrid);
|
||
let cabinCarouselIndex = 0;
|
||
|
||
const cabinCardsPerView = () => 2;
|
||
|
||
const scrollToCabinCard = (index, smooth = true) => {
|
||
const visibleCount = cabinCardsPerView();
|
||
const maxIndex = Math.max(0, cabinCards.length - visibleCount);
|
||
cabinCarouselIndex = Math.max(0, Math.min(index, maxIndex));
|
||
const firstOffset = cabinCards[0]?.offsetLeft || 0;
|
||
const targetOffset = cabinCards[cabinCarouselIndex]?.offsetLeft || firstOffset;
|
||
domainGrid.scrollTo({
|
||
left: Math.max(0, targetOffset - firstOffset),
|
||
behavior: smooth ? "smooth" : "auto",
|
||
});
|
||
};
|
||
|
||
const updateCabinCarouselState = () => {
|
||
const visibleCount = cabinCardsPerView();
|
||
domainGrid.style.setProperty("--cabin-cards-per-view", String(visibleCount));
|
||
const maxIndex = Math.max(0, cabinCards.length - visibleCount);
|
||
const firstCard = cabinCards[0];
|
||
const secondCard = cabinCards[1];
|
||
const cardStep = firstCard && secondCard
|
||
? Math.max(1, secondCard.offsetLeft - firstCard.offsetLeft)
|
||
: Math.max(1, firstCard?.offsetWidth || domainGrid.clientWidth);
|
||
cabinCarouselIndex = Math.max(0, Math.min(Math.round(domainGrid.scrollLeft / cardStep), maxIndex));
|
||
const range = $("#cabinCarouselRange", cabinToolbar);
|
||
if (range) {
|
||
range.textContent = `${cabinCarouselIndex + 1}–${Math.min(cabinCards.length, cabinCarouselIndex + visibleCount)}`;
|
||
}
|
||
const previous = $("#cabinCarouselPrev", cabinToolbar);
|
||
const next = $("#cabinCarouselNext", cabinToolbar);
|
||
if (previous) previous.disabled = cabinCarouselIndex <= 0;
|
||
if (next) next.disabled = cabinCarouselIndex >= maxIndex;
|
||
};
|
||
|
||
const moveCabinCarousel = (direction) => {
|
||
scrollToCabinCard(cabinCarouselIndex + direction * cabinCardsPerView());
|
||
};
|
||
|
||
const setCabinExpanded = (expanded) => {
|
||
shell.classList.toggle("cabin-expanded", expanded);
|
||
const button = $("#cabinExpandButton", cabinBand);
|
||
if (!button) return;
|
||
button.setAttribute("aria-expanded", String(expanded));
|
||
const icon = $("svg", button);
|
||
if (icon) icon.outerHTML = `<i data-lucide="${expanded ? "minimize-2" : "maximize-2"}"></i>`;
|
||
$("span", button).textContent = expanded ? "返回孪生" : "展开查看";
|
||
refreshIcons();
|
||
window.dispatchEvent(new Event("resize"));
|
||
requestAnimationFrame(() => {
|
||
updateCabinCarouselState();
|
||
scrollToCabinCard(cabinCarouselIndex, false);
|
||
});
|
||
};
|
||
|
||
$("#cabinCarouselPrev", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(-1));
|
||
$("#cabinCarouselNext", cabinToolbar)?.addEventListener("click", () => moveCabinCarousel(1));
|
||
$("#cabinExpandButton", cabinBand)?.addEventListener("click", (event) => {
|
||
setCabinExpanded(event.currentTarget.getAttribute("aria-expanded") !== "true");
|
||
});
|
||
window.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Escape") return;
|
||
if (!powerDrawer.hidden) {
|
||
closePowerDrawer();
|
||
} else if (shell.classList.contains("cabin-expanded")) {
|
||
setCabinExpanded(false);
|
||
}
|
||
});
|
||
|
||
const updateCabinConnection = (status = state.connectionStatus) => {
|
||
renderCabinLiveStatus(status);
|
||
};
|
||
window.addEventListener("cognitive-agent:connection", (event) => {
|
||
updateCabinConnection(event.detail?.status || "offline");
|
||
});
|
||
updateCabinConnection();
|
||
|
||
domainGrid.addEventListener("scroll", updateCabinCarouselState, { passive: true });
|
||
if ("ResizeObserver" in window) {
|
||
const domainGridObserver = new ResizeObserver(() => {
|
||
updateCabinCarouselState();
|
||
scrollToCabinCard(cabinCarouselIndex, false);
|
||
});
|
||
domainGridObserver.observe(domainGrid);
|
||
}
|
||
requestAnimationFrame(() => {
|
||
updateCabinCarouselState();
|
||
scrollToCabinCard(0, false);
|
||
});
|
||
coverage.classList.add("joint-hardware-boundary");
|
||
shell.append(coverage);
|
||
stateBand.remove();
|
||
}
|
||
|
||
function twinAngleForPosition(axis, positionPct) {
|
||
const range = TWIN_ANGLE_RANGES[axis];
|
||
if (!range) return null;
|
||
return range[0] + (range[1] - range[0]) * Number(positionPct) / 100;
|
||
}
|
||
|
||
function isEstimatedMotion(device, axis) {
|
||
const motion = device?.position?.[axis];
|
||
return Boolean(motion?.position_estimated || motion?.position_valid === false);
|
||
}
|
||
|
||
function twinPostureAngles(device) {
|
||
const position = (axis, fallback) => Number(
|
||
device?.position?.[axis]?.position_pct ?? fallback,
|
||
);
|
||
const included = (axis, fallback) => Number(
|
||
device?.position?.[axis]?.angle_deg
|
||
?? twinAngleForPosition(axis, position(axis, fallback)),
|
||
);
|
||
const cushion = position("cushion_tilt", 50) * 0.09;
|
||
return {
|
||
cushion_tilt: cushion,
|
||
backrest_recline: included("backrest_recline", 20)
|
||
- TWIN_ANGLE_RANGES.backrest_recline[0],
|
||
legrest_height: included("legrest_height", 0)
|
||
- TWIN_ANGLE_RANGES.legrest_height[0],
|
||
};
|
||
}
|
||
|
||
function makeDynamicLine(THREE, parent, pointCount, material, segments = false) {
|
||
const positions = new Float32Array(pointCount * 3);
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
const line = segments
|
||
? new THREE.LineSegments(geometry, material)
|
||
: new THREE.Line(geometry, material);
|
||
line.frustumCulled = false;
|
||
line.renderOrder = 20;
|
||
parent.add(line);
|
||
return { line, positions, attribute: geometry.getAttribute("position") };
|
||
}
|
||
|
||
function createTwinAngleGuide(THREE, parent, color) {
|
||
const material = new THREE.LineBasicMaterial({
|
||
color,
|
||
transparent: true,
|
||
opacity: 0.95,
|
||
depthTest: false,
|
||
});
|
||
const group = new THREE.Group();
|
||
parent.add(group);
|
||
const dotMaterial = new THREE.MeshBasicMaterial({
|
||
color,
|
||
transparent: true,
|
||
opacity: 0.96,
|
||
depthTest: false,
|
||
});
|
||
const dotGeometry = new THREE.SphereGeometry(0.022, 8, 8);
|
||
const dots = Array.from({ length: 13 }, () => {
|
||
const dot = new THREE.Mesh(dotGeometry, dotMaterial);
|
||
dot.renderOrder = 21;
|
||
group.add(dot);
|
||
return dot;
|
||
});
|
||
dots[0].scale.setScalar(1.6);
|
||
dots[dots.length - 1].scale.setScalar(1.6);
|
||
return {
|
||
group,
|
||
arc: makeDynamicLine(THREE, group, 49, material),
|
||
rays: makeDynamicLine(THREE, group, 4, material, true),
|
||
dots,
|
||
};
|
||
}
|
||
|
||
function updateTwinAngleGuide(guide, x, pivotY, pivotZ, startDeg, sweepDeg, radius) {
|
||
for (let index = 0; index < 49; index += 1) {
|
||
const angle = (startDeg + sweepDeg * index / 48) * Math.PI / 180;
|
||
const offset = index * 3;
|
||
guide.arc.positions[offset] = x;
|
||
guide.arc.positions[offset + 1] = pivotY + Math.sin(angle) * radius;
|
||
guide.arc.positions[offset + 2] = pivotZ + Math.cos(angle) * radius;
|
||
}
|
||
const endpoints = [
|
||
[x, pivotY, pivotZ],
|
||
[guide.arc.positions[0], guide.arc.positions[1], guide.arc.positions[2]],
|
||
[x, pivotY, pivotZ],
|
||
[
|
||
guide.arc.positions[guide.arc.positions.length - 3],
|
||
guide.arc.positions[guide.arc.positions.length - 2],
|
||
guide.arc.positions[guide.arc.positions.length - 1],
|
||
],
|
||
];
|
||
endpoints.flat().forEach((value, index) => {
|
||
guide.rays.positions[index] = value;
|
||
});
|
||
guide.dots.forEach((dot, index) => {
|
||
const source = index * 4 * 3;
|
||
dot.position.set(
|
||
guide.arc.positions[source],
|
||
guide.arc.positions[source + 1],
|
||
guide.arc.positions[source + 2],
|
||
);
|
||
});
|
||
guide.arc.attribute.needsUpdate = true;
|
||
guide.rays.attribute.needsUpdate = true;
|
||
}
|
||
|
||
function makeTwinBox(THREE, parent, size, position, material, edgeMaterial) {
|
||
const geometry = new THREE.BoxGeometry(...size);
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.position.set(...position);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 32), edgeMaterial);
|
||
mesh.add(edges);
|
||
parent.add(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
function makeCabinBeam(THREE, parent, start, end, radius, material) {
|
||
const from = new THREE.Vector3(...start);
|
||
const to = new THREE.Vector3(...end);
|
||
const direction = to.clone().sub(from);
|
||
const beam = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(radius, radius, direction.length(), 12),
|
||
material,
|
||
);
|
||
beam.position.copy(from).add(to).multiplyScalar(0.5);
|
||
beam.quaternion.setFromUnitVectors(
|
||
new THREE.Vector3(0, 1, 0),
|
||
direction.normalize(),
|
||
);
|
||
beam.castShadow = true;
|
||
parent.add(beam);
|
||
return beam;
|
||
}
|
||
|
||
function createTwinAirflowField(THREE, parent) {
|
||
const count = 56;
|
||
const positions = new Float32Array(count * 3);
|
||
const seeds = Array.from({ length: count }, (_, index) => (
|
||
(index * 0.61803398875) % 1
|
||
));
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
color: 0x55bdec,
|
||
size: 0.055,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
depthTest: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const points = new THREE.Points(geometry, material);
|
||
points.frustumCulled = false;
|
||
points.renderOrder = 24;
|
||
parent.add(points);
|
||
return {
|
||
points,
|
||
positions,
|
||
seeds,
|
||
attribute: geometry.getAttribute("position"),
|
||
material,
|
||
level: 0,
|
||
};
|
||
}
|
||
|
||
function makeVehicleProfileShape(THREE) {
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-3.72, 0.2);
|
||
shape.lineTo(-3.72, 0.92);
|
||
shape.quadraticCurveTo(-3.6, 1.28, -3.12, 1.47);
|
||
shape.quadraticCurveTo(-2.42, 1.51, -1.52, 1.45);
|
||
shape.lineTo(1.86, 1.45);
|
||
shape.quadraticCurveTo(2.06, 1.47, 2.22, 1.52);
|
||
shape.quadraticCurveTo(2.88, 1.52, 3.48, 1.28);
|
||
shape.quadraticCurveTo(3.76, 1.12, 3.74, 0.72);
|
||
shape.lineTo(3.68, 0.24);
|
||
shape.lineTo(3.12, 0.18);
|
||
shape.bezierCurveTo(3.02, 0.76, 2.8, 1.3, 2.35, 1.3);
|
||
shape.bezierCurveTo(1.9, 1.3, 1.68, 0.76, 1.58, 0.18);
|
||
shape.lineTo(-1.48, 0.18);
|
||
shape.bezierCurveTo(-1.58, 0.76, -1.8, 1.3, -2.25, 1.3);
|
||
shape.bezierCurveTo(-2.7, 1.3, -2.92, 0.76, -3.02, 0.18);
|
||
shape.lineTo(-3.72, 0.2);
|
||
shape.closePath();
|
||
return shape;
|
||
}
|
||
|
||
function makeVehicleGreenhouseShape(THREE) {
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-2.62, 1.31);
|
||
shape.quadraticCurveTo(-2.28, 1.8, -1.82, 2.42);
|
||
shape.quadraticCurveTo(-0.48, 2.68, 0.92, 2.5);
|
||
shape.quadraticCurveTo(1.58, 2.4, 2.18, 1.31);
|
||
shape.closePath();
|
||
return shape;
|
||
}
|
||
|
||
function makeVehicleSideGlassShape(THREE) {
|
||
const shape = new THREE.Shape();
|
||
shape.moveTo(-2.58, 1.32);
|
||
shape.lineTo(-1.79, 2.36);
|
||
shape.quadraticCurveTo(-0.5, 2.56, 1.03, 2.42);
|
||
shape.lineTo(1.82, 1.32);
|
||
shape.closePath();
|
||
return shape;
|
||
}
|
||
|
||
function makeRoundedPanelShape(THREE, width, length, radius) {
|
||
const shape = new THREE.Shape();
|
||
const halfWidth = width / 2;
|
||
const halfLength = length / 2;
|
||
shape.moveTo(-halfWidth + radius, -halfLength);
|
||
shape.lineTo(halfWidth - radius, -halfLength);
|
||
shape.quadraticCurveTo(halfWidth, -halfLength, halfWidth, -halfLength + radius);
|
||
shape.lineTo(halfWidth, halfLength - radius);
|
||
shape.quadraticCurveTo(halfWidth, halfLength, halfWidth - radius, halfLength);
|
||
shape.lineTo(-halfWidth + radius, halfLength);
|
||
shape.quadraticCurveTo(-halfWidth, halfLength, -halfWidth, halfLength - radius);
|
||
shape.lineTo(-halfWidth, -halfLength + radius);
|
||
shape.quadraticCurveTo(-halfWidth, -halfLength, -halfWidth + radius, -halfLength);
|
||
shape.closePath();
|
||
return shape;
|
||
}
|
||
|
||
function createTwinRearBench(THREE) {
|
||
const root = new THREE.Group();
|
||
root.position.set(0, 0.12, -1.72);
|
||
root.userData.twinRole = "fixed_rear_bench";
|
||
|
||
const leather = new THREE.MeshPhysicalMaterial({
|
||
color: 0x35484e,
|
||
roughness: 0.58,
|
||
metalness: 0.01,
|
||
clearcoat: 0.12,
|
||
clearcoatRoughness: 0.75,
|
||
});
|
||
const insert = new THREE.MeshPhysicalMaterial({
|
||
color: 0x829497,
|
||
roughness: 0.72,
|
||
clearcoat: 0.05,
|
||
});
|
||
const structure = new THREE.MeshStandardMaterial({
|
||
color: 0x26343a,
|
||
roughness: 0.68,
|
||
metalness: 0.25,
|
||
});
|
||
const buckleMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0xb64b43,
|
||
roughness: 0.5,
|
||
});
|
||
const edge = new THREE.LineBasicMaterial({
|
||
color: 0x19272b,
|
||
transparent: true,
|
||
opacity: 0.42,
|
||
});
|
||
|
||
makeTwinBox(THREE, root, [2.5, 0.18, 0.72], [0, 0.24, 0.08], structure, edge);
|
||
const cushion = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 1.82, 7, 24), leather);
|
||
cushion.rotation.z = Math.PI / 2;
|
||
cushion.scale.set(0.92, 1, 1.23);
|
||
cushion.position.set(0, 0.46, 0.08);
|
||
cushion.castShadow = true;
|
||
cushion.receiveShadow = true;
|
||
root.add(cushion);
|
||
|
||
const backrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 1.78, 7, 24), leather);
|
||
backrest.rotation.z = Math.PI / 2;
|
||
backrest.scale.set(1.46, 1, 0.43);
|
||
backrest.position.set(0, 1.04, -0.68);
|
||
backrest.castShadow = true;
|
||
root.add(backrest);
|
||
|
||
[-0.79, 0, 0.79].forEach((x) => {
|
||
const cushionInsert = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.13, 0.42, 6, 18),
|
||
insert,
|
||
);
|
||
cushionInsert.rotation.z = Math.PI / 2;
|
||
cushionInsert.scale.set(0.52, 1, 2.05);
|
||
cushionInsert.position.set(x, 0.71, 0.08);
|
||
cushionInsert.castShadow = true;
|
||
root.add(cushionInsert);
|
||
|
||
const backInsert = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.18, 0.42, 6, 18),
|
||
insert,
|
||
);
|
||
backInsert.scale.set(1.65, 1, 0.42);
|
||
backInsert.position.set(x, 1.05, -0.535);
|
||
backInsert.castShadow = true;
|
||
root.add(backInsert);
|
||
|
||
makeCabinBeam(THREE, root, [x - 0.11, 1.43, -0.7], [x - 0.11, 1.55, -0.7], 0.018, structure);
|
||
makeCabinBeam(THREE, root, [x + 0.11, 1.43, -0.7], [x + 0.11, 1.55, -0.7], 0.018, structure);
|
||
const headrest = new THREE.Mesh(
|
||
new THREE.CapsuleGeometry(0.18, 0.16, 7, 18),
|
||
leather,
|
||
);
|
||
headrest.scale.set(1.25, 1, 0.55);
|
||
headrest.position.set(x, 1.62, -0.7);
|
||
headrest.castShadow = true;
|
||
root.add(headrest);
|
||
});
|
||
|
||
[-0.4, 0.4].forEach((x) => {
|
||
makeTwinBox(THREE, root, [0.09, 0.075, 0.12], [x, 0.73, -0.04], buckleMaterial, edge);
|
||
});
|
||
return root;
|
||
}
|
||
|
||
function createTwinCabin(THREE, scene) {
|
||
const cabin = new THREE.Group();
|
||
const trim = new THREE.MeshStandardMaterial({
|
||
color: 0x25343a,
|
||
roughness: 0.6,
|
||
metalness: 0.2,
|
||
transparent: true,
|
||
opacity: 0.7,
|
||
depthWrite: false,
|
||
});
|
||
const softTrim = new THREE.MeshStandardMaterial({
|
||
color: 0x506167,
|
||
roughness: 0.8,
|
||
transparent: true,
|
||
opacity: 0.5,
|
||
depthWrite: false,
|
||
});
|
||
const cockpit = new THREE.MeshStandardMaterial({
|
||
color: 0x243238,
|
||
roughness: 0.6,
|
||
metalness: 0.18,
|
||
});
|
||
const consoleTop = new THREE.MeshStandardMaterial({
|
||
color: 0x5c6c70,
|
||
roughness: 0.66,
|
||
metalness: 0.12,
|
||
});
|
||
const accent = new THREE.MeshStandardMaterial({
|
||
color: 0x26364c,
|
||
emissive: 0x78efff,
|
||
emissiveIntensity: 0.18,
|
||
roughness: 0.22,
|
||
metalness: 0.18,
|
||
depthTest: false,
|
||
});
|
||
const ambientHousingMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x17242c,
|
||
roughness: 0.38,
|
||
metalness: 0.56,
|
||
emissive: 0x08141a,
|
||
emissiveIntensity: 0.18,
|
||
});
|
||
const ambientMeshes = [];
|
||
const invisibleEdge = new THREE.LineBasicMaterial({ transparent: true, opacity: 0 });
|
||
|
||
const shellMaterial = new THREE.MeshPhysicalMaterial({
|
||
color: 0x84adb7,
|
||
roughness: 0.18,
|
||
metalness: 0.1,
|
||
transmission: 0.48,
|
||
transparent: true,
|
||
opacity: 0.17,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide,
|
||
clearcoat: 0.9,
|
||
clearcoatRoughness: 0.18,
|
||
});
|
||
const glassMaterial = new THREE.MeshPhysicalMaterial({
|
||
color: 0x6f9eae,
|
||
roughness: 0.06,
|
||
transmission: 0.86,
|
||
transparent: true,
|
||
opacity: 0.22,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
const shellEdge = new THREE.LineBasicMaterial({
|
||
color: 0x456975,
|
||
transparent: true,
|
||
opacity: 0.56,
|
||
});
|
||
const roofEntityEdge = new THREE.LineBasicMaterial({
|
||
color: 0x2879d8,
|
||
transparent: true,
|
||
opacity: 0.98,
|
||
depthTest: true,
|
||
});
|
||
const ventEntityEdge = new THREE.LineBasicMaterial({
|
||
color: 0x9eeaff,
|
||
transparent: true,
|
||
opacity: 1,
|
||
depthTest: false,
|
||
});
|
||
|
||
const bodyShell = makeTwinPanel(
|
||
THREE,
|
||
cabin,
|
||
makeVehicleProfileShape(THREE),
|
||
3.16,
|
||
[0, 0, 0],
|
||
[0, -Math.PI / 2, 0],
|
||
shellMaterial,
|
||
shellEdge,
|
||
);
|
||
bodyShell.castShadow = false;
|
||
bodyShell.receiveShadow = false;
|
||
bodyShell.renderOrder = 2;
|
||
|
||
const greenhouseMaterial = glassMaterial.clone();
|
||
greenhouseMaterial.color.setHex(0x769ead);
|
||
greenhouseMaterial.opacity = 0.12;
|
||
const greenhouse = makeTwinPanel(
|
||
THREE,
|
||
cabin,
|
||
makeVehicleGreenhouseShape(THREE),
|
||
2.64,
|
||
[0, 0, 0],
|
||
[0, -Math.PI / 2, 0],
|
||
greenhouseMaterial,
|
||
shellEdge,
|
||
);
|
||
greenhouse.castShadow = false;
|
||
greenhouse.renderOrder = 3;
|
||
|
||
const bodyPanelMaterial = shellMaterial.clone();
|
||
bodyPanelMaterial.color.setHex(0x6f9da8);
|
||
bodyPanelMaterial.transmission = 0.34;
|
||
bodyPanelMaterial.opacity = 0.3;
|
||
const exteriorTrim = new THREE.MeshStandardMaterial({
|
||
color: 0x1e2a2f,
|
||
roughness: 0.52,
|
||
metalness: 0.3,
|
||
});
|
||
const hood = makeTwinPanel(
|
||
THREE,
|
||
cabin,
|
||
makeRoundedPanelShape(THREE, 2.68, 1.44, 0.18),
|
||
0.05,
|
||
[0, 1.43, 2.88],
|
||
[-Math.PI / 2 + 0.1, 0, 0],
|
||
bodyPanelMaterial,
|
||
shellEdge,
|
||
);
|
||
hood.castShadow = false;
|
||
hood.renderOrder = 3;
|
||
makeTwinBox(THREE, cabin, [2.82, 0.42, 0.14], [0, 0.57, 3.57], bodyPanelMaterial, invisibleEdge);
|
||
makeTwinBox(THREE, cabin, [2.82, 0.38, 0.14], [0, 0.54, -3.55], bodyPanelMaterial, invisibleEdge);
|
||
makeTwinBox(THREE, cabin, [1.1, 0.18, 0.055], [0, 0.61, 3.655], exteriorTrim, invisibleEdge);
|
||
makeTwinBox(THREE, cabin, [0.68, 0.055, 0.035], [0, 0.82, -3.635], exteriorTrim, invisibleEdge);
|
||
for (const side of [-1, 1]) {
|
||
makeTwinBox(THREE, cabin, [0.12, 0.2, 2.88], [side * 1.5, 0.34, 0.02], bodyPanelMaterial, shellEdge);
|
||
makeCabinBeam(THREE, cabin, [side * 1.615, 1.3, 1.82], [side * 1.615, 1.3, -2.56], 0.012, exteriorTrim);
|
||
makeCabinBeam(THREE, cabin, [side * 1.62, 0.4, 0.18], [side * 1.62, 1.29, 0.18], 0.01, exteriorTrim);
|
||
makeCabinBeam(THREE, cabin, [side * 1.62, 0.38, -1.48], [side * 1.62, 1.29, -1.48], 0.01, exteriorTrim);
|
||
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, 0.58], exteriorTrim, invisibleEdge);
|
||
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, -1.2], exteriorTrim, invisibleEdge);
|
||
|
||
const mirror = new THREE.Mesh(new THREE.SphereGeometry(0.18, 18, 12), bodyPanelMaterial);
|
||
mirror.scale.set(0.56, 0.68, 1.24);
|
||
mirror.position.set(side * 1.76, 1.48, 1.3);
|
||
mirror.castShadow = true;
|
||
cabin.add(mirror);
|
||
makeCabinBeam(THREE, cabin, [side * 1.52, 1.4, 1.31], [side * 1.68, 1.46, 1.3], 0.025, exteriorTrim);
|
||
}
|
||
|
||
[-1, 1].forEach((side) => {
|
||
const sideGlass = makeTwinPanel(
|
||
THREE,
|
||
cabin,
|
||
makeVehicleSideGlassShape(THREE),
|
||
0.025,
|
||
[side * 1.335, 0, 0],
|
||
[0, -Math.PI / 2, 0],
|
||
glassMaterial,
|
||
shellEdge,
|
||
);
|
||
sideGlass.castShadow = false;
|
||
sideGlass.renderOrder = 4;
|
||
});
|
||
const windshield = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[2.48, 1.43, 0.035],
|
||
[0, 1.88, 1.57],
|
||
glassMaterial,
|
||
shellEdge,
|
||
);
|
||
windshield.rotation.x = -0.49;
|
||
windshield.castShadow = false;
|
||
windshield.renderOrder = 4;
|
||
const rearGlass = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[2.38, 1.28, 0.035],
|
||
[0, 1.88, -2.26],
|
||
glassMaterial,
|
||
shellEdge,
|
||
);
|
||
rearGlass.rotation.x = 0.6;
|
||
rearGlass.castShadow = false;
|
||
rearGlass.renderOrder = 4;
|
||
|
||
const roofGlassMaterial = glassMaterial.clone();
|
||
roofGlassMaterial.color.setHex(0x4b9bea);
|
||
roofGlassMaterial.emissive.setHex(0x164f91);
|
||
roofGlassMaterial.emissiveIntensity = 0.38;
|
||
roofGlassMaterial.transmission = 0;
|
||
roofGlassMaterial.transparent = true;
|
||
roofGlassMaterial.opacity = 0.52;
|
||
roofGlassMaterial.depthWrite = false;
|
||
const roofGlass = makeTwinPanel(
|
||
THREE,
|
||
cabin,
|
||
makeRoundedPanelShape(THREE, 1.08, 1.96, 0.16),
|
||
0.038,
|
||
[0, 2.43, -0.61],
|
||
[-Math.PI / 2, 0, 0],
|
||
roofGlassMaterial,
|
||
roofEntityEdge,
|
||
);
|
||
roofGlass.castShadow = false;
|
||
roofGlass.renderOrder = 7;
|
||
const roofFrameMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x1b6fc4,
|
||
emissive: 0x164f91,
|
||
emissiveIntensity: 0.38,
|
||
roughness: 0.24,
|
||
metalness: 0.62,
|
||
transparent: true,
|
||
opacity: 0.88,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
});
|
||
const roofMeshes = [roofGlass];
|
||
[
|
||
[[1.22, 0.055, 0.075], [0, 2.455, 0.405]],
|
||
[[1.22, 0.055, 0.075], [0, 2.455, -1.625]],
|
||
[[0.075, 0.055, 1.96], [-0.59, 2.455, -0.61]],
|
||
[[0.075, 0.055, 1.96], [0.59, 2.455, -0.61]],
|
||
].forEach(([size, position]) => {
|
||
roofMeshes.push(makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
size,
|
||
position,
|
||
roofFrameMaterial,
|
||
invisibleEdge,
|
||
));
|
||
});
|
||
const roofShadeMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x5f6d71,
|
||
roughness: 0.86,
|
||
transparent: true,
|
||
opacity: 0.06,
|
||
depthWrite: false,
|
||
});
|
||
const roofShade = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[1.02, 0.025, 1.88],
|
||
[0, 2.39, -1.55],
|
||
roofShadeMaterial,
|
||
invisibleEdge,
|
||
);
|
||
roofShade.scale.z = 0.015;
|
||
roofShade.renderOrder = 6;
|
||
|
||
for (const side of [-1, 1]) {
|
||
makeCabinBeam(THREE, cabin, [side * 1.47, 1.25, 1.9], [side * 1.25, 2.51, 1.19], 0.045, trim);
|
||
makeCabinBeam(THREE, cabin, [side * 1.5, 1.25, -2.55], [side * 1.25, 2.48, -1.78], 0.05, trim);
|
||
makeCabinBeam(THREE, cabin, [side * 1.56, 1.25, -0.32], [side * 1.34, 2.54, -0.32], 0.045, trim);
|
||
makeCabinBeam(THREE, cabin, [side * 1.24, 2.57, 1.27], [side * 1.24, 2.57, -1.84], 0.045, trim);
|
||
}
|
||
makeCabinBeam(THREE, cabin, [-1.23, 2.58, 1.3], [1.23, 2.58, 1.3], 0.045, trim);
|
||
makeCabinBeam(THREE, cabin, [-1.23, 2.58, -1.9], [1.23, 2.58, -1.9], 0.045, trim);
|
||
|
||
const cabinFloor = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[2.86, 0.08, 5.45],
|
||
[0, 0.13, -0.05],
|
||
new THREE.MeshStandardMaterial({ color: 0x2b393e, roughness: 0.92, transparent: true, opacity: 0.5 }),
|
||
invisibleEdge,
|
||
);
|
||
cabinFloor.receiveShadow = true;
|
||
|
||
for (const side of [-1, 1]) {
|
||
makeTwinBox(THREE, cabin, [0.065, 0.62, 3.34], [side * 1.46, 0.67, -0.18], softTrim, invisibleEdge);
|
||
makeCabinBeam(THREE, cabin, [side * 1.415, 1.02, 1.3], [side * 1.415, 1.02, -1.76], 0.012, trim);
|
||
}
|
||
|
||
makeTwinBox(THREE, cabin, [0.42, 0.36, 1.55], [0, 0.4, 0.42], cockpit, invisibleEdge);
|
||
makeTwinBox(THREE, cabin, [0.38, 0.035, 1.28], [0, 0.6, 0.4], consoleTop, invisibleEdge);
|
||
|
||
const dashboard = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 2.55, 7, 28), softTrim);
|
||
dashboard.rotation.z = Math.PI / 2;
|
||
dashboard.position.set(0, 1.38, 1.63);
|
||
dashboard.scale.z = 1.5;
|
||
dashboard.castShadow = true;
|
||
cabin.add(dashboard);
|
||
const gloveboxLightHousing = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[1.18, 0.135, 0.075],
|
||
[-0.75, 1.265, 1.43],
|
||
ambientHousingMaterial,
|
||
invisibleEdge,
|
||
);
|
||
ambientMeshes.push(gloveboxLightHousing);
|
||
for (let index = 0; index < 9; index += 1) {
|
||
const lightSegment = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[0.09, 0.042, 0.028],
|
||
[-1.21 + index * 0.115, 1.315, 1.385],
|
||
accent,
|
||
invisibleEdge,
|
||
);
|
||
lightSegment.userData.twinRole = "passenger_glovebox_ambient_light";
|
||
ambientMeshes.push(lightSegment);
|
||
}
|
||
makeTwinBox(THREE, cabin, [0.54, 0.3, 0.055], [0.82, 1.62, 1.49], cockpit, invisibleEdge);
|
||
|
||
const steeringWheel = new THREE.Group();
|
||
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.036, 10, 36), cockpit);
|
||
steeringWheel.add(wheel);
|
||
makeTwinBox(THREE, steeringWheel, [0.035, 0.22, 0.028], [0, -0.1, 0], cockpit, invisibleEdge);
|
||
makeTwinBox(THREE, steeringWheel, [0.23, 0.035, 0.028], [0, -0.02, 0], cockpit, invisibleEdge);
|
||
steeringWheel.position.set(0.82, 1.48, 1.24);
|
||
steeringWheel.rotation.x = -0.34;
|
||
cabin.add(steeringWheel);
|
||
makeCabinBeam(THREE, cabin, [0.82, 1.43, 1.27], [0.82, 1.34, 1.57], 0.024, cockpit);
|
||
|
||
const tireMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x172126,
|
||
roughness: 0.92,
|
||
metalness: 0.05,
|
||
});
|
||
const rimMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x798a8f,
|
||
roughness: 0.38,
|
||
metalness: 0.72,
|
||
});
|
||
[-1, 1].forEach((side) => {
|
||
[-2.25, 2.35].forEach((z) => {
|
||
const tire = new THREE.Mesh(
|
||
new THREE.TorusGeometry(0.53, 0.15, 14, 32),
|
||
tireMaterial,
|
||
);
|
||
tire.position.set(side * 1.49, 0.58, z);
|
||
tire.rotation.y = Math.PI / 2;
|
||
tire.castShadow = true;
|
||
cabin.add(tire);
|
||
const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.11, 24), rimMaterial);
|
||
rim.position.copy(tire.position);
|
||
rim.rotation.z = Math.PI / 2;
|
||
cabin.add(rim);
|
||
|
||
const archLip = new THREE.Mesh(
|
||
new THREE.TorusGeometry(0.69, 0.035, 8, 36, Math.PI),
|
||
bodyPanelMaterial,
|
||
);
|
||
archLip.position.set(side * 1.615, 0.58, z);
|
||
archLip.rotation.y = Math.PI / 2;
|
||
archLip.castShadow = true;
|
||
cabin.add(archLip);
|
||
|
||
const spokeX = side * 1.555;
|
||
for (let spoke = 0; spoke < 5; spoke += 1) {
|
||
const angle = spoke * Math.PI * 2 / 5;
|
||
makeCabinBeam(
|
||
THREE,
|
||
cabin,
|
||
[spokeX, 0.58, z],
|
||
[spokeX, 0.58 + Math.cos(angle) * 0.27, z + Math.sin(angle) * 0.27],
|
||
0.022,
|
||
rimMaterial,
|
||
);
|
||
}
|
||
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 8), exteriorTrim);
|
||
hub.position.set(spokeX, 0.58, z);
|
||
hub.scale.x = 0.45;
|
||
cabin.add(hub);
|
||
});
|
||
});
|
||
|
||
const lampMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x5d7076,
|
||
emissive: 0xb9ecff,
|
||
emissiveIntensity: 0.08,
|
||
roughness: 0.34,
|
||
});
|
||
const rearLampMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x697b80,
|
||
emissive: 0x000000,
|
||
emissiveIntensity: 0,
|
||
roughness: 0.56,
|
||
});
|
||
const headlampMeshes = [];
|
||
for (const side of [-1, 1]) {
|
||
const headlamp = new THREE.Mesh(new THREE.SphereGeometry(0.16, 18, 10), lampMaterial);
|
||
headlamp.scale.set(2.6, 0.75, 0.24);
|
||
headlamp.position.set(side * 1.03, 1.05, 3.61);
|
||
cabin.add(headlamp);
|
||
headlampMeshes.push(headlamp);
|
||
const rearLamp = new THREE.Mesh(new THREE.SphereGeometry(0.15, 18, 10), rearLampMaterial);
|
||
rearLamp.scale.set(2.35, 0.7, 0.22);
|
||
rearLamp.position.set(side * 1.08, 1.08, -3.57);
|
||
cabin.add(rearLamp);
|
||
}
|
||
makeTwinBox(THREE, cabin, [1.08, 0.22, 0.035], [0, 0.72, 3.69], cockpit, invisibleEdge);
|
||
|
||
const ventMaterials = [];
|
||
const ventMeshes = [];
|
||
const ventCoreMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x07141c,
|
||
roughness: 0.5,
|
||
metalness: 0.34,
|
||
});
|
||
const ventBladeMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0xd8f2f7,
|
||
emissive: 0x62dfff,
|
||
emissiveIntensity: 0.48,
|
||
roughness: 0.2,
|
||
metalness: 0.82,
|
||
depthTest: false,
|
||
});
|
||
[-1.14, -0.34, 0.34, 1.14].forEach((x) => {
|
||
const ventMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x176c94,
|
||
roughness: 0.24,
|
||
metalness: 0.55,
|
||
emissive: 0x2ccff3,
|
||
emissiveIntensity: 0.62,
|
||
});
|
||
ventMaterials.push(ventMaterial);
|
||
const vent = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[Math.abs(x) > 1 ? 0.47 : 0.44, 0.2, 0.135],
|
||
[x, 1.38, 1.405],
|
||
ventMaterial,
|
||
ventEntityEdge,
|
||
);
|
||
vent.userData.twinRole = "dashboard_hvac_outlet";
|
||
ventMeshes.push(vent);
|
||
const coreWidth = Math.abs(x) > 1 ? 0.365 : 0.34;
|
||
makeTwinBox(
|
||
THREE,
|
||
vent,
|
||
[coreWidth, 0.12, 0.145],
|
||
[0, 0, 0],
|
||
ventCoreMaterial,
|
||
invisibleEdge,
|
||
);
|
||
for (const bladeY of [-0.038, 0, 0.038]) {
|
||
makeTwinBox(
|
||
THREE,
|
||
vent,
|
||
[coreWidth * 0.9, 0.014, 0.025],
|
||
[0, bladeY, -0.083],
|
||
ventBladeMaterial,
|
||
invisibleEdge,
|
||
);
|
||
}
|
||
});
|
||
const rearVentMaterial = new THREE.MeshStandardMaterial({
|
||
color: 0x203741,
|
||
roughness: 0.34,
|
||
metalness: 0.42,
|
||
emissive: 0x2d91b6,
|
||
emissiveIntensity: 0.3,
|
||
});
|
||
ventMaterials.push(rearVentMaterial);
|
||
[-0.1, 0.1].forEach((x) => {
|
||
const vent = makeTwinBox(
|
||
THREE,
|
||
cabin,
|
||
[0.18, 0.13, 0.065],
|
||
[x, 0.48, -0.375],
|
||
rearVentMaterial,
|
||
ventEntityEdge,
|
||
);
|
||
ventMeshes.push(vent);
|
||
});
|
||
const airflow = createTwinAirflowField(THREE, cabin);
|
||
|
||
const ambientLights = [
|
||
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
|
||
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
|
||
];
|
||
ambientLights[0].position.set(-1.06, 1.22, 1.08);
|
||
ambientLights[1].position.set(-0.48, 1.22, 1.08);
|
||
ambientLights.forEach((light) => cabin.add(light));
|
||
|
||
const fallbackExteriorMaterials = new Set([
|
||
shellMaterial,
|
||
greenhouseMaterial,
|
||
bodyPanelMaterial,
|
||
exteriorTrim,
|
||
glassMaterial,
|
||
tireMaterial,
|
||
lampMaterial,
|
||
rearLampMaterial,
|
||
]);
|
||
const fallbackExteriorObjects = [];
|
||
const fallbackInteriorMaterials = new Set([trim, softTrim, cockpit, consoleTop]);
|
||
const fallbackInteriorObjects = [];
|
||
const worldPosition = new THREE.Vector3();
|
||
cabin.updateMatrixWorld(true);
|
||
cabin.traverse((object) => {
|
||
if (!object.isMesh) return;
|
||
const isFallbackMaterial = fallbackExteriorMaterials.has(object.material);
|
||
object.getWorldPosition(worldPosition);
|
||
const isWheelDetail = (
|
||
object.material === rimMaterial
|
||
&& Math.abs(worldPosition.z) > 1.8
|
||
);
|
||
if (isFallbackMaterial || isWheelDetail) fallbackExteriorObjects.push(object);
|
||
if (fallbackInteriorMaterials.has(object.material) || object === cabinFloor) {
|
||
fallbackInteriorObjects.push(object);
|
||
}
|
||
});
|
||
|
||
scene.add(cabin);
|
||
return {
|
||
root: cabin,
|
||
fallbackExteriorObjects,
|
||
fallbackInteriorObjects,
|
||
themeMaterials: {
|
||
vehicleBody: [shellMaterial, bodyPanelMaterial],
|
||
vehicleDetail: [softTrim, consoleTop],
|
||
vehicleInterior: [trim, cockpit],
|
||
vehicleTrim: [exteriorTrim, tireMaterial],
|
||
vehicleMetal: [rimMaterial],
|
||
vehicleOutline: [shellEdge],
|
||
glass: [glassMaterial, greenhouseMaterial],
|
||
},
|
||
shellMaterial,
|
||
glassMaterial,
|
||
referenceWindowPanes: {
|
||
front_left: [],
|
||
front_right: [],
|
||
rear_left: [],
|
||
rear_right: [],
|
||
},
|
||
roofGlass,
|
||
roofMeshes,
|
||
roofGlassMaterial,
|
||
roofFrameMaterial,
|
||
roofEdgeMaterial: roofEntityEdge,
|
||
roofShade,
|
||
roofShadeMaterial,
|
||
ambientMaterial: accent,
|
||
ambientMeshes,
|
||
ambientLights,
|
||
headlampMaterial: lampMaterial,
|
||
headlampMeshes,
|
||
referenceFrontLampMaterials: [],
|
||
referenceFrontLampMeshes: [],
|
||
referenceFrontLampHighlights: [],
|
||
referenceFrontLampLensOutlines: [],
|
||
ventMaterials,
|
||
ventMeshes,
|
||
airflow,
|
||
colors: {
|
||
ambientTarget: new THREE.Color(0x6fb6ff),
|
||
ambientIdle: new THREE.Color(0x1f3f65),
|
||
climateTarget: new THREE.Color(0x5da7ee),
|
||
roofClear: new THREE.Color(0x1f68b6),
|
||
roofDark: new THREE.Color(0x071b3e),
|
||
},
|
||
targets: {
|
||
ambientPower: 0,
|
||
ambientBrightness: 0,
|
||
climateLevel: 0,
|
||
climateTemperature: 24,
|
||
roofTransmittance: 100,
|
||
roofShadePosition: 0,
|
||
windowPositions: {
|
||
front_left: 0,
|
||
front_right: 0,
|
||
rear_left: 0,
|
||
rear_right: 0,
|
||
},
|
||
lowBeam: 0,
|
||
highBeam: 0,
|
||
fogLight: 0,
|
||
turnSignal: 0,
|
||
},
|
||
current: {
|
||
ambientBrightness: 0,
|
||
climateLevel: 0,
|
||
roofTransmittance: 100,
|
||
roofShadePosition: 0,
|
||
windowPositions: {
|
||
front_left: 0,
|
||
front_right: 0,
|
||
rear_left: 0,
|
||
rear_right: 0,
|
||
},
|
||
lowBeam: 0,
|
||
highBeam: 0,
|
||
fogLight: 0,
|
||
turnSignal: 0,
|
||
},
|
||
layout: {
|
||
roofShadeCenterZ: -0.61,
|
||
roofShadeHalfLength: 0.94,
|
||
},
|
||
};
|
||
}
|
||
|
||
const TWIN_THEME_PALETTES = {
|
||
dark: {
|
||
accent: 0x7b70f5,
|
||
accentStrong: 0x5d4dd9,
|
||
accentSoft: 0x9ba8ff,
|
||
sceneBackground: 0x111426,
|
||
floor: 0x1a2038,
|
||
gridStrong: 0x758cff,
|
||
gridSoft: 0x3c496d,
|
||
fillLight: 0x93b4ff,
|
||
vehicleBody: 0x729bc9,
|
||
vehicleDetail: 0x557da8,
|
||
vehicleInterior: 0x344f72,
|
||
vehicleTrim: 0x1a2943,
|
||
vehicleMetal: 0x7896b5,
|
||
vehicleOutline: 0x73b8ff,
|
||
vehicleOccluded: 0x526583,
|
||
seatShell: 0x426fbd,
|
||
seatInsert: 0xa9c8ea,
|
||
seatThermal: 0x65a9ed,
|
||
seatBolster: 0x295694,
|
||
seatStructure: 0x263b5e,
|
||
seatEdge: 0x76bdff,
|
||
seatSeam: 0xcbe4ff,
|
||
glass: 0x4d8ed2,
|
||
glassEmissive: 0x183f76,
|
||
glassEdge: 0x397ac0,
|
||
glassGlow: 0x79c7ff,
|
||
roofClear: 0x649cda,
|
||
roofDark: 0x1d315c,
|
||
lamp: 0x4385cf,
|
||
lampEmissive: 0x55acf4,
|
||
lampEdge: 0x3476b9,
|
||
},
|
||
light: {
|
||
accent: 0x2f80ed,
|
||
accentStrong: 0x1d5fbf,
|
||
accentSoft: 0xdbeeff,
|
||
sceneBackground: 0xeff6ff,
|
||
floor: 0xe5f0fd,
|
||
gridStrong: 0x75a9e8,
|
||
gridSoft: 0xc7daf2,
|
||
fillLight: 0xb7d9ff,
|
||
vehicleBody: 0x91c6f5,
|
||
vehicleDetail: 0x77a9d8,
|
||
vehicleInterior: 0x4d79a6,
|
||
vehicleTrim: 0x284c70,
|
||
vehicleMetal: 0x93b9da,
|
||
vehicleOutline: 0x2879d8,
|
||
vehicleOccluded: 0x9abbdc,
|
||
seatShell: 0x2b76c9,
|
||
seatInsert: 0xcfe7ff,
|
||
seatThermal: 0x72b5f5,
|
||
seatBolster: 0x1b5fae,
|
||
seatStructure: 0x25476e,
|
||
seatEdge: 0x69b4ff,
|
||
seatSeam: 0xe4f2ff,
|
||
glass: 0x4b9bea,
|
||
glassEmissive: 0x164f91,
|
||
glassEdge: 0x1b6fc4,
|
||
glassGlow: 0x79c7ff,
|
||
roofClear: 0x67aef4,
|
||
roofDark: 0x183b70,
|
||
lamp: 0x2e7fd4,
|
||
lampEmissive: 0x48a4f2,
|
||
lampEdge: 0x1e6bb8,
|
||
},
|
||
};
|
||
|
||
function currentTwinThemeRole() {
|
||
return currentGlassTheme();
|
||
}
|
||
|
||
function currentTwinThemePalette() {
|
||
return TWIN_THEME_PALETTES[currentTwinThemeRole()];
|
||
}
|
||
|
||
const TWIN_DEVICE_HOTSPOT_LAYOUT = Object.freeze({
|
||
windows: { normalized: [0.08, 0.62, 0.54], labelOffset: [-115, 0] },
|
||
shade: { normalized: [0.5, 0.94, 0.43], labelOffset: [0, -65] },
|
||
driver: { normalized: [0.68, 0.55, 0.52], labelOffset: [110, -70] },
|
||
passenger: { normalized: [0.32, 0.48, 0.58], labelOffset: [-105, 55] },
|
||
reading: { normalized: [0.28, 0.86, 0.64], labelOffset: [-120, -70] },
|
||
ambient: { normalized: [0.28, 0.52, 0.78], labelOffset: [-30, 85] },
|
||
headlight: { normalized: [0.18, 0.4, 0.98], labelOffset: [15, 70] },
|
||
climate: { normalized: [0.38, 0.55, 0.78], labelOffset: [94, 5] },
|
||
});
|
||
|
||
function createTwinGroundTexture(THREE, palette, size = 768) {
|
||
const textureCanvas = document.createElement("canvas");
|
||
textureCanvas.width = size;
|
||
textureCanvas.height = size;
|
||
const context = textureCanvas.getContext("2d");
|
||
if (!context) return null;
|
||
|
||
const floorColor = new THREE.Color(palette.floor);
|
||
const gridStrong = new THREE.Color(palette.gridStrong);
|
||
const gridSoft = new THREE.Color(palette.gridSoft);
|
||
const rgba = (color, alpha) => {
|
||
const red = Math.round(color.r * 255);
|
||
const green = Math.round(color.g * 255);
|
||
const blue = Math.round(color.b * 255);
|
||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||
};
|
||
const center = size / 2;
|
||
// Keep the ground dark and translucent so it reads as another layer of the
|
||
// glass workspace instead of a white rectangle inside the themed shell.
|
||
const outerRadius = size * 0.48;
|
||
|
||
context.clearRect(0, 0, size, size);
|
||
const surfaceGradient = context.createRadialGradient(
|
||
center,
|
||
center,
|
||
0,
|
||
center,
|
||
center,
|
||
outerRadius,
|
||
);
|
||
surfaceGradient.addColorStop(0, rgba(floorColor, 0.96));
|
||
surfaceGradient.addColorStop(0.2, rgba(floorColor, 0.9));
|
||
surfaceGradient.addColorStop(0.52, rgba(floorColor, 0.58));
|
||
surfaceGradient.addColorStop(0.78, rgba(floorColor, 0.2));
|
||
surfaceGradient.addColorStop(1, rgba(floorColor, 0));
|
||
context.fillStyle = surfaceGradient;
|
||
context.fillRect(0, 0, size, size);
|
||
|
||
const gridCanvas = document.createElement("canvas");
|
||
gridCanvas.width = size;
|
||
gridCanvas.height = size;
|
||
const gridContext = gridCanvas.getContext("2d");
|
||
const gridStep = size / 40;
|
||
for (let index = 0; index <= 40; index += 1) {
|
||
const position = index * gridStep;
|
||
const major = index % 5 === 0;
|
||
gridContext.strokeStyle = rgba(major ? gridStrong : gridSoft, major ? 0.38 : 0.24);
|
||
gridContext.lineWidth = major ? 0.9 : 0.45;
|
||
gridContext.beginPath();
|
||
gridContext.moveTo(position, 0);
|
||
gridContext.lineTo(position, size);
|
||
gridContext.stroke();
|
||
gridContext.beginPath();
|
||
gridContext.moveTo(0, position);
|
||
gridContext.lineTo(size, position);
|
||
gridContext.stroke();
|
||
}
|
||
const gridMask = gridContext.createRadialGradient(
|
||
center,
|
||
center,
|
||
0,
|
||
center,
|
||
center,
|
||
outerRadius,
|
||
);
|
||
gridMask.addColorStop(0, "rgba(255, 255, 255, 0.64)");
|
||
gridMask.addColorStop(0.22, "rgba(255, 255, 255, 0.56)");
|
||
gridMask.addColorStop(0.52, "rgba(255, 255, 255, 0.36)");
|
||
gridMask.addColorStop(0.8, "rgba(255, 255, 255, 0.1)");
|
||
gridMask.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||
gridContext.globalCompositeOperation = "destination-in";
|
||
gridContext.fillStyle = gridMask;
|
||
gridContext.fillRect(0, 0, size, size);
|
||
context.drawImage(gridCanvas, 0, 0);
|
||
|
||
const texture = new THREE.CanvasTexture(textureCanvas);
|
||
if ("colorSpace" in texture) texture.colorSpace = THREE.SRGBColorSpace;
|
||
else texture.encoding = THREE.sRGBEncoding;
|
||
texture.generateMipmaps = true;
|
||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||
texture.magFilter = THREE.LinearFilter;
|
||
texture.needsUpdate = true;
|
||
return texture;
|
||
}
|
||
|
||
function applyTwinGroundTheme(twin, palette) {
|
||
if (!twin?.floor?.material || !window.THREE) return;
|
||
const nextTexture = createTwinGroundTexture(window.THREE, palette);
|
||
const previousTexture = twin.floor.material.map;
|
||
twin.floor.material.map = nextTexture;
|
||
twin.floor.material.color.setHex(0xffffff);
|
||
twin.floor.material.needsUpdate = true;
|
||
if (previousTexture && previousTexture !== nextTexture) previousTexture.dispose();
|
||
}
|
||
|
||
function applyTwinVehicleTheme(systems, palette = currentTwinThemePalette()) {
|
||
if (!systems) return;
|
||
systems.themeRole = currentTwinThemeRole();
|
||
systems.themePalette = palette;
|
||
Object.entries(systems.themeMaterials || {}).forEach(([token, materials]) => {
|
||
const color = palette[token];
|
||
if (color === undefined) return;
|
||
materials.forEach((material) => {
|
||
if (material?.color) material.color.setHex(color);
|
||
});
|
||
});
|
||
systems.colors.roofClear.setHex(palette.roofClear);
|
||
systems.colors.roofDark.setHex(palette.roofDark);
|
||
|
||
if (systems.roofGlassMaterial.color) systems.roofGlassMaterial.color.setHex(palette.glass);
|
||
if (systems.roofGlassMaterial.emissive) {
|
||
systems.roofGlassMaterial.emissive.setHex(palette.glassEmissive);
|
||
}
|
||
if (systems.roofFrameMaterial?.color) systems.roofFrameMaterial.color.setHex(palette.glassEdge);
|
||
if (systems.roofFrameMaterial?.emissive) {
|
||
systems.roofFrameMaterial.emissive.setHex(palette.glassEmissive);
|
||
}
|
||
if (systems.roofEdgeMaterial?.color) systems.roofEdgeMaterial.color.setHex(palette.glassEdge);
|
||
|
||
Object.values(systems.referenceWindowPanes).flat().forEach((pane) => {
|
||
(pane.materials || []).forEach((material) => {
|
||
if (material.color) material.color.setHex(palette.glass);
|
||
if (material.emissive) material.emissive.setHex(palette.glassEmissive);
|
||
});
|
||
if (pane.outline?.material?.color) pane.outline.material.color.setHex(palette.glassEdge);
|
||
});
|
||
|
||
systems.referenceFrontLampMaterials.forEach((material) => {
|
||
if (material.color) material.color.setHex(palette.lamp);
|
||
if (material.emissive) material.emissive.setHex(palette.lampEmissive);
|
||
});
|
||
systems.referenceFrontLampHighlights.forEach(({ material, isHalo }) => {
|
||
if (material.color) material.color.setHex(isHalo ? palette.glassGlow : palette.lamp);
|
||
});
|
||
systems.referenceFrontLampLensOutlines.forEach(({ mesh, outline }) => {
|
||
if (mesh.material?.color) mesh.material.color.setHex(palette.lamp);
|
||
if (outline?.material?.color) outline.material.color.setHex(palette.lampEdge);
|
||
});
|
||
}
|
||
|
||
function applyTwinSeatTheme(model, palette) {
|
||
if (!model?.materials) return;
|
||
const {
|
||
shell,
|
||
insert,
|
||
thermalInsert,
|
||
bolster,
|
||
structure,
|
||
edge,
|
||
seam,
|
||
} = model.materials;
|
||
shell.color.setHex(palette.seatShell);
|
||
shell.emissive.setHex(palette.accentStrong);
|
||
shell.emissiveIntensity = 0.14;
|
||
insert.color.setHex(palette.seatInsert);
|
||
insert.emissive.setHex(palette.accentStrong);
|
||
insert.emissiveIntensity = 0.1;
|
||
thermalInsert.color.setHex(palette.seatThermal);
|
||
thermalInsert.emissive.setHex(palette.accentStrong);
|
||
thermalInsert.emissiveIntensity = 0.12;
|
||
bolster.color.setHex(palette.seatBolster);
|
||
bolster.emissive.setHex(palette.accentStrong);
|
||
bolster.emissiveIntensity = 0.12;
|
||
structure.color.setHex(palette.seatStructure);
|
||
structure.emissive.setHex(palette.seatStructure);
|
||
structure.emissiveIntensity = 0.08;
|
||
edge.color.setHex(palette.seatEdge);
|
||
seam.color.setHex(palette.seatSeam);
|
||
model.thermalColors.base.setHex(palette.seatThermal);
|
||
Object.values(model.angleGuides || {}).forEach((guide) => {
|
||
guide.group.traverse((object) => {
|
||
if (object.material?.color) object.material.color.setHex(palette.accent);
|
||
});
|
||
});
|
||
model.root.traverse((object) => {
|
||
if (object.userData.twinOutline?.material?.color) {
|
||
object.userData.twinOutline.material.color.setHex(palette.seatEdge);
|
||
}
|
||
if (object.userData.twinOccludedOutline?.material?.color) {
|
||
object.userData.twinOccludedOutline.material.color.setHex(palette.accent);
|
||
}
|
||
const ghost = object.userData.twinOccludedGhost;
|
||
if (ghost) {
|
||
const materials = Array.isArray(ghost.material) ? ghost.material : [ghost.material];
|
||
materials.filter(Boolean).forEach((material) => material.color.setHex(palette.accent));
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyTwinReferenceVehicleTheme(root, palette) {
|
||
if (!root) return;
|
||
root.traverse((object) => {
|
||
if (!object.isMesh) return;
|
||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||
materials.filter(Boolean).forEach((material) => {
|
||
const token = material.userData?.twinThemeToken;
|
||
if (token && palette[token] !== undefined && material.color) {
|
||
material.color.setHex(palette[token]);
|
||
material.needsUpdate = true;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function applyTwinSceneTheme(twin) {
|
||
if (!twin) return;
|
||
const role = currentTwinThemeRole();
|
||
const palette = currentTwinThemePalette();
|
||
applyTwinVehicleTheme(twin.vehicleSystems, palette);
|
||
twin.scene.background.setHex(palette.sceneBackground);
|
||
if (twin.scene.fog?.color) twin.scene.fog.color.setHex(palette.sceneBackground);
|
||
applyTwinGroundTheme(twin, palette);
|
||
if (twin.fillLight?.color) twin.fillLight.color.setHex(palette.fillLight);
|
||
Object.values(twin.models || {}).forEach((model) => applyTwinSeatTheme(model, palette));
|
||
applyTwinReferenceVehicleTheme(twin.referenceVehicle, palette);
|
||
[twin.vehicleSystems?.root, twin.referenceVehicle, twin.fixedModels?.rear]
|
||
.filter(Boolean)
|
||
.forEach((root) => root.traverse((object) => {
|
||
if (object.userData.twinOutline?.material?.color) {
|
||
object.userData.twinOutline.material.color.setHex(palette.vehicleOutline);
|
||
}
|
||
if (object.userData.twinOccludedOutline?.material?.color) {
|
||
object.userData.twinOccludedOutline.material.color.setHex(palette.vehicleOccluded);
|
||
}
|
||
}));
|
||
// Controllable glass and lamp entities use stronger semantic variants from
|
||
// the same palette after the neutral vehicle outline pass above.
|
||
applyTwinVehicleTheme(twin.vehicleSystems, palette);
|
||
const canvas = twin.renderer?.domElement;
|
||
if (canvas) {
|
||
canvas.dataset.themeRole = role;
|
||
canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`;
|
||
}
|
||
}
|
||
|
||
function updateTwinVehicleVisual(systems, timestamp) {
|
||
if (!systems) return;
|
||
const ease = (current, target, factor = 0.08) => (
|
||
Math.abs(target - current) < 0.002 ? target : current + (target - current) * factor
|
||
);
|
||
systems.current.ambientBrightness = ease(
|
||
systems.current.ambientBrightness,
|
||
systems.targets.ambientPower ? systems.targets.ambientBrightness : 0,
|
||
);
|
||
systems.current.climateLevel = ease(
|
||
systems.current.climateLevel,
|
||
systems.targets.climateLevel,
|
||
);
|
||
systems.current.roofTransmittance = ease(
|
||
systems.current.roofTransmittance,
|
||
systems.targets.roofTransmittance,
|
||
);
|
||
systems.current.roofShadePosition = ease(
|
||
systems.current.roofShadePosition,
|
||
systems.targets.roofShadePosition,
|
||
);
|
||
Object.keys(systems.current.windowPositions).forEach((key) => {
|
||
systems.current.windowPositions[key] = ease(
|
||
systems.current.windowPositions[key],
|
||
systems.targets.windowPositions[key],
|
||
0.1,
|
||
);
|
||
});
|
||
["lowBeam", "highBeam", "fogLight", "turnSignal"].forEach((key) => {
|
||
systems.current[key] = ease(systems.current[key], systems.targets[key], 0.12);
|
||
});
|
||
|
||
const ambientStrength = systems.current.ambientBrightness / 100;
|
||
const ambientDisplayColor = systems.colors.ambientIdle.clone().lerp(
|
||
systems.colors.ambientTarget,
|
||
0.34 + ambientStrength * 0.66,
|
||
);
|
||
systems.ambientMaterial.color.lerp(ambientDisplayColor, 0.1);
|
||
systems.ambientMaterial.emissive.lerp(systems.colors.ambientTarget, 0.1);
|
||
systems.ambientMaterial.emissiveIntensity = 0.28 + ambientStrength * 2.15;
|
||
systems.ambientMaterial.transparent = false;
|
||
systems.ambientMaterial.opacity = 1;
|
||
systems.ambientMaterial.depthWrite = true;
|
||
systems.ambientLights.forEach((light) => {
|
||
light.color.lerp(systems.colors.ambientTarget, 0.1);
|
||
light.intensity = ambientStrength * 2.4;
|
||
});
|
||
|
||
const roofRatio = systems.current.roofTransmittance / 100;
|
||
const roofColor = systems.colors.roofDark.clone().lerp(systems.colors.roofClear, roofRatio);
|
||
systems.roofGlassMaterial.color.lerp(roofColor, 0.08);
|
||
systems.roofGlassMaterial.opacity = 0.52 + (1 - roofRatio) * 0.18;
|
||
const shadeCoverage = Math.max(0.015, systems.current.roofShadePosition / 100);
|
||
systems.roofShade.scale.z = shadeCoverage;
|
||
systems.roofShade.position.z = systems.layout.roofShadeCenterZ
|
||
- (1 - shadeCoverage) * systems.layout.roofShadeHalfLength;
|
||
systems.roofShadeMaterial.opacity = 0.08 + shadeCoverage * 0.82;
|
||
|
||
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
|
||
const openingRatio = systems.current.windowPositions[key] / 100;
|
||
panes.forEach((pane) => {
|
||
pane.mesh.position.lerpVectors(pane.closedPosition, pane.openPosition, openingRatio);
|
||
});
|
||
});
|
||
|
||
const mainLampStrength = Math.max(
|
||
systems.current.lowBeam * 0.62,
|
||
systems.current.highBeam,
|
||
);
|
||
const isHighBeam = systems.current.highBeam > systems.current.lowBeam * 0.62;
|
||
const fogStrength = systems.current.fogLight;
|
||
const turnPulse = systems.targets.turnSignal
|
||
? 0.2 + 0.8 * (Math.sin(timestamp * 0.008) > 0 ? 1 : 0)
|
||
: systems.current.turnSignal;
|
||
const lampStrength = Math.max(mainLampStrength, fogStrength * 0.72, turnPulse);
|
||
const lampColor = turnPulse > Math.max(mainLampStrength, fogStrength * 0.72)
|
||
? 0xffa627
|
||
: (fogStrength * 0.72 > mainLampStrength ? 0xfff0b3 : (isHighBeam ? 0xeafaff : 0xcdefff));
|
||
const updateFrontLampMaterial = (material, isReference = false) => {
|
||
const isLit = lampStrength > 0.02;
|
||
const palette = systems.themePalette || currentTwinThemePalette();
|
||
if (material.color) material.color.setHex(isLit ? lampColor : palette.lamp);
|
||
if (material.emissive) material.emissive.setHex(isLit ? lampColor : palette.lampEmissive);
|
||
if ("emissiveIntensity" in material) {
|
||
material.emissiveIntensity = (isReference ? 0.18 : 0.08) + lampStrength * 3.4;
|
||
}
|
||
if (isReference) {
|
||
material.transparent = true;
|
||
material.opacity = 0.9 + lampStrength * 0.1;
|
||
material.depthWrite = lampStrength > 0.55;
|
||
}
|
||
material.needsUpdate = true;
|
||
};
|
||
updateFrontLampMaterial(systems.headlampMaterial);
|
||
systems.referenceFrontLampMaterials.forEach((material) => updateFrontLampMaterial(material, true));
|
||
systems.referenceFrontLampHighlights.forEach(({
|
||
material,
|
||
idleOpacity,
|
||
activeOpacity,
|
||
isHalo,
|
||
}) => {
|
||
const palette = systems.themePalette || currentTwinThemePalette();
|
||
const idleColor = isHalo ? palette.glassGlow : palette.lamp;
|
||
material.color.setHex(lampStrength > 0.02 ? lampColor : idleColor);
|
||
material.opacity = idleOpacity + lampStrength * (activeOpacity - idleOpacity);
|
||
});
|
||
|
||
const climateStrength = systems.current.climateLevel / 7;
|
||
const climateColor = systems.targets.climateTemperature < 24
|
||
? 0x55bdec
|
||
: systems.targets.climateTemperature > 24
|
||
? 0xffa05d
|
||
: 0x72d1d0;
|
||
systems.colors.climateTarget.setHex(climateColor);
|
||
systems.airflow.level = ease(systems.airflow.level, climateStrength, 0.1);
|
||
systems.airflow.material.color.lerp(systems.colors.climateTarget, 0.12);
|
||
systems.airflow.material.opacity = systems.airflow.level * 0.92;
|
||
systems.airflow.material.size = 0.04 + systems.airflow.level * 0.055;
|
||
systems.airflow.points.visible = systems.airflow.level > 0.01;
|
||
systems.ventMaterials.forEach((material) => {
|
||
material.transparent = false;
|
||
material.opacity = 1;
|
||
material.depthWrite = true;
|
||
material.emissive.lerp(systems.colors.climateTarget, 0.12);
|
||
material.emissiveIntensity = 0.3 + systems.airflow.level * 2;
|
||
});
|
||
const speed = 0.00018 + systems.airflow.level * 0.00042;
|
||
systems.airflow.seeds.forEach((seed, index) => {
|
||
const progress = (timestamp * speed + seed) % 1;
|
||
const lane = index % 8;
|
||
const offset = index * 3;
|
||
systems.airflow.positions[offset] = -1.22 + lane * 0.13 + Math.sin(progress * 9 + index) * 0.035;
|
||
systems.airflow.positions[offset + 1] = 1.35 - progress * 0.5 + Math.sin(progress * Math.PI) * 0.13;
|
||
systems.airflow.positions[offset + 2] = 1.38 - progress * 1.68;
|
||
});
|
||
systems.airflow.attribute.needsUpdate = true;
|
||
}
|
||
|
||
function makeTwinPanel(THREE, parent, shape, depth, position, rotation, material, edgeMaterial) {
|
||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||
depth,
|
||
steps: 1,
|
||
curveSegments: 18,
|
||
bevelEnabled: true,
|
||
bevelSegments: 4,
|
||
bevelSize: 0.045,
|
||
bevelThickness: 0.04,
|
||
});
|
||
geometry.translate(0, 0, -depth / 2);
|
||
geometry.computeVertexNormals();
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.position.set(...position);
|
||
mesh.rotation.set(...rotation);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 48), edgeMaterial);
|
||
mesh.add(edges);
|
||
parent.add(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
function makeBackrestShape(THREE, inset = 0) {
|
||
const shape = new THREE.Shape();
|
||
const half = 0.7 - inset;
|
||
shape.moveTo(-0.52 + inset, 0);
|
||
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
|
||
shape.lineTo(-half + 0.035, 1.28 - inset);
|
||
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
|
||
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
|
||
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
|
||
shape.lineTo(half, 0.34);
|
||
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
|
||
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
|
||
return shape;
|
||
}
|
||
|
||
function makeCushionShape(THREE, inset = 0) {
|
||
const shape = new THREE.Shape();
|
||
const half = 0.69 - inset;
|
||
shape.moveTo(-0.52 + inset, 0.61 - inset);
|
||
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
|
||
shape.lineTo(-half + 0.025, -0.39 + inset);
|
||
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
|
||
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
|
||
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
|
||
shape.lineTo(half, 0.34);
|
||
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
|
||
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
|
||
return shape;
|
||
}
|
||
|
||
function makeTwinSeam(THREE, parent, points, material) {
|
||
const geometry = new THREE.BufferGeometry().setFromPoints(
|
||
points.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
|
||
);
|
||
const seam = new THREE.Line(geometry, material);
|
||
parent.add(seam);
|
||
return seam;
|
||
}
|
||
|
||
function createTwinVentField(THREE, parent, kind) {
|
||
const count = 12;
|
||
const positions = new Float32Array(count * 3);
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
color: 0x39bdf2,
|
||
size: 0.055,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
depthTest: false,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const points = new THREE.Points(geometry, material);
|
||
points.frustumCulled = false;
|
||
points.renderOrder = 18;
|
||
parent.add(points);
|
||
return {
|
||
kind,
|
||
points,
|
||
positions,
|
||
attribute: geometry.getAttribute("position"),
|
||
material,
|
||
};
|
||
}
|
||
|
||
function createTwinHeatPulse(THREE, parent, kind, phase) {
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: 0xff633f,
|
||
transparent: true,
|
||
opacity: 0,
|
||
depthWrite: false,
|
||
depthTest: false,
|
||
side: THREE.DoubleSide,
|
||
blending: THREE.AdditiveBlending,
|
||
});
|
||
const mesh = new THREE.Mesh(
|
||
new THREE.RingGeometry(0.17, 0.205, 32),
|
||
material,
|
||
);
|
||
if (kind === "backrest") {
|
||
mesh.position.set(0, 0.88, 0.285);
|
||
mesh.scale.set(1.8, 3, 1);
|
||
} else {
|
||
mesh.position.set(0, 0.205, 0.04);
|
||
mesh.rotation.x = -Math.PI / 2;
|
||
mesh.scale.set(1.75, 2.35, 1);
|
||
}
|
||
mesh.visible = false;
|
||
mesh.renderOrder = 17;
|
||
parent.add(mesh);
|
||
return { mesh, material, phase, baseScale: mesh.scale.clone() };
|
||
}
|
||
|
||
function updateTwinThermalVisual(model, timestamp) {
|
||
const smoothing = 0.075;
|
||
["ventilation", "heating"].forEach((feature) => {
|
||
const delta = model.thermalTargets[feature] - model.thermalLevels[feature];
|
||
model.thermalLevels[feature] += Math.abs(delta) < 0.002 ? delta : delta * smoothing;
|
||
});
|
||
|
||
const ventilation = model.thermalLevels.ventilation;
|
||
const heating = model.thermalLevels.heating;
|
||
const selectionFactor = model.isSelected ? 1 : 0.58;
|
||
const targetColor = model.thermalColors.base.clone();
|
||
targetColor.lerp(model.thermalColors.ventilation, ventilation * 0.5);
|
||
targetColor.lerp(model.thermalColors.heating, heating * 0.6);
|
||
model.insertMaterial.color.lerp(targetColor, 0.1);
|
||
const targetEmissive = model.thermalColors.ventilation.clone().multiplyScalar(
|
||
ventilation * 0.14,
|
||
);
|
||
targetEmissive.lerp(
|
||
model.thermalColors.heating,
|
||
Math.min(1, heating * 0.34),
|
||
);
|
||
model.insertMaterial.emissive.lerp(targetEmissive, 0.1);
|
||
model.insertMaterial.emissiveIntensity = 0.12 + Math.max(ventilation, heating) * 0.38;
|
||
|
||
const elapsed = timestamp * (0.0003 + ventilation * 0.00018);
|
||
model.ventFields.forEach((field, fieldIndex) => {
|
||
field.points.visible = ventilation > 0.015;
|
||
field.material.opacity = ventilation * selectionFactor * 0.92;
|
||
field.material.size = 0.045 + ventilation * 0.045;
|
||
for (let index = 0; index < field.positions.length / 3; index += 1) {
|
||
const progress = (elapsed + index / 12 + fieldIndex * 0.17) % 1;
|
||
const column = (index % 3) - 1;
|
||
const row = Math.floor(index / 3);
|
||
const offset = index * 3;
|
||
if (field.kind === "backrest") {
|
||
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
|
||
field.positions[offset + 1] = 0.27 + progress * 1.28;
|
||
field.positions[offset + 2] = 0.29 + Math.sin(progress * Math.PI) * 0.22;
|
||
} else {
|
||
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
|
||
field.positions[offset + 1] = 0.205 + progress * 0.36;
|
||
field.positions[offset + 2] = -0.36 + row * 0.24 + Math.sin(progress * Math.PI) * 0.05;
|
||
}
|
||
}
|
||
field.attribute.needsUpdate = true;
|
||
});
|
||
|
||
model.heatPulses.forEach((pulse) => {
|
||
const progress = (timestamp * 0.00042 + pulse.phase) % 1;
|
||
const scale = 0.78 + progress * 0.42;
|
||
pulse.mesh.visible = heating > 0.015;
|
||
pulse.mesh.scale.copy(pulse.baseScale).multiplyScalar(scale);
|
||
pulse.material.opacity = heating * selectionFactor * (1 - progress) * 0.38;
|
||
});
|
||
}
|
||
|
||
function createTwinSeat(THREE, x, accentColor) {
|
||
const root = new THREE.Group();
|
||
root.position.set(x, 0.14, 0.05);
|
||
|
||
const shell = new THREE.MeshPhysicalMaterial({
|
||
color: 0x344950,
|
||
roughness: 0.5,
|
||
metalness: 0.01,
|
||
clearcoat: 0.16,
|
||
clearcoatRoughness: 0.72,
|
||
});
|
||
const insert = new THREE.MeshPhysicalMaterial({
|
||
color: 0x9fb1b2,
|
||
roughness: 0.68,
|
||
metalness: 0,
|
||
clearcoat: 0.08,
|
||
});
|
||
const thermalInsert = insert.clone();
|
||
const bolster = new THREE.MeshStandardMaterial({ color: 0x52686f, roughness: 0.72 });
|
||
const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.64, metalness: 0.32 });
|
||
const rail = new THREE.MeshStandardMaterial({ color: 0x65757a, roughness: 0.45, metalness: 0.7 });
|
||
const edge = new THREE.LineBasicMaterial({ color: 0x182529, transparent: true, opacity: 0.34 });
|
||
const seam = new THREE.LineBasicMaterial({ color: accentColor, transparent: true, opacity: 0.88 });
|
||
const trackedMaterials = [shell, insert, thermalInsert, bolster];
|
||
|
||
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], rail, edge);
|
||
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [0.48, 0.12, 0], rail, edge);
|
||
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structure, edge);
|
||
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structure, edge);
|
||
|
||
const slide = new THREE.Group();
|
||
root.add(slide);
|
||
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structure);
|
||
base.rotation.x = Math.PI / 2;
|
||
base.scale.set(1.42, 1, 0.55);
|
||
base.position.set(0, 0.34, -0.02);
|
||
base.castShadow = true;
|
||
base.receiveShadow = true;
|
||
slide.add(base);
|
||
|
||
const cushionPivot = new THREE.Group();
|
||
cushionPivot.position.set(0, 0.57, 0.06);
|
||
slide.add(cushionPivot);
|
||
makeTwinPanel(
|
||
THREE,
|
||
cushionPivot,
|
||
makeCushionShape(THREE),
|
||
0.22,
|
||
[0, 0, 0.08],
|
||
[-Math.PI / 2, 0, 0],
|
||
shell,
|
||
edge,
|
||
);
|
||
makeTwinPanel(
|
||
THREE,
|
||
cushionPivot,
|
||
makeCushionShape(THREE, 0.16),
|
||
0.055,
|
||
[0, 0.145, 0.03],
|
||
[-Math.PI / 2, 0, 0],
|
||
thermalInsert,
|
||
edge,
|
||
);
|
||
for (const side of [-1, 1]) {
|
||
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolster);
|
||
wing.rotation.x = Math.PI / 2;
|
||
wing.position.set(side * 0.61, 0.17, 0.08);
|
||
wing.castShadow = true;
|
||
cushionPivot.add(wing);
|
||
}
|
||
makeTwinSeam(THREE, cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]], seam);
|
||
makeTwinSeam(THREE, cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]], seam);
|
||
const cushionVentField = createTwinVentField(THREE, cushionPivot, "cushion");
|
||
|
||
const backrestPivot = new THREE.Group();
|
||
backrestPivot.position.set(0, 0.62, -0.5);
|
||
slide.add(backrestPivot);
|
||
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE), 0.3, [0, 0, 0], [0, 0, 0], shell, edge);
|
||
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE, 0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert, edge);
|
||
for (const side of [-1, 1]) {
|
||
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolster);
|
||
wing.position.set(side * 0.61, 0.78, 0.18);
|
||
wing.scale.set(1, 1, 0.72);
|
||
wing.castShadow = true;
|
||
backrestPivot.add(wing);
|
||
}
|
||
makeTwinSeam(THREE, backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]], seam);
|
||
makeTwinSeam(THREE, backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]], seam);
|
||
makeTwinSeam(THREE, backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]], seam);
|
||
const backrestVentField = createTwinVentField(THREE, backrestPivot, "backrest");
|
||
const heatPulses = [
|
||
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0),
|
||
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0.5),
|
||
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.18),
|
||
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.68),
|
||
];
|
||
|
||
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], rail, edge);
|
||
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], rail, edge);
|
||
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), shell);
|
||
headrest.scale.set(1.35, 1, 0.5);
|
||
headrest.position.set(0, 1.98, 0.01);
|
||
headrest.castShadow = true;
|
||
backrestPivot.add(headrest);
|
||
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), insert);
|
||
headrestInsert.scale.set(1.32, 1, 0.28);
|
||
headrestInsert.position.set(0, 1.98, 0.18);
|
||
headrestInsert.castShadow = true;
|
||
backrestPivot.add(headrestInsert);
|
||
|
||
const legrestPivot = new THREE.Group();
|
||
legrestPivot.position.set(0, 0.64, 0.7);
|
||
slide.add(legrestPivot);
|
||
const legrestExtension = new THREE.Group();
|
||
legrestPivot.add(legrestExtension);
|
||
makeTwinPanel(
|
||
THREE,
|
||
legrestExtension,
|
||
makeCushionShape(THREE, 0.08),
|
||
0.18,
|
||
[0, 0, 0.36],
|
||
[-Math.PI / 2, 0, 0],
|
||
shell,
|
||
edge,
|
||
).scale.set(0.9, 0.72, 0.54);
|
||
makeTwinPanel(
|
||
THREE,
|
||
legrestExtension,
|
||
makeCushionShape(THREE, 0.2),
|
||
0.045,
|
||
[0, 0.12, 0.4],
|
||
[-Math.PI / 2, 0, 0],
|
||
insert,
|
||
edge,
|
||
).scale.set(0.92, 0.74, 0.56);
|
||
|
||
const outerSide = Math.sign(x);
|
||
const anchors = {};
|
||
const addAnchor = (name, parent, position) => {
|
||
const anchor = new THREE.Object3D();
|
||
anchor.position.set(...position);
|
||
parent.add(anchor);
|
||
anchors[name] = anchor;
|
||
};
|
||
addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]);
|
||
addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]);
|
||
addAnchor("height", slide, [outerSide * 0.84, 0.25, -0.12]);
|
||
addAnchor("backrest_recline", backrestPivot, [outerSide * 0.7, 1.28, 0.2]);
|
||
addAnchor("legrest_height", legrestExtension, [outerSide * 0.7, 0.08, 0.58]);
|
||
addAnchor("legrest_extension", legrestExtension, [outerSide * 0.7, 0.1, 0.82]);
|
||
addAnchor("backrest_included_angle", slide, [outerSide * 0.83, 0.62, -0.5]);
|
||
addAnchor("legrest_included_angle", slide, [-outerSide * 0.7, 0.51, 0.7]);
|
||
const angleGuides = {
|
||
backrest_recline: createTwinAngleGuide(THREE, slide, accentColor),
|
||
legrest_height: createTwinAngleGuide(THREE, slide, accentColor),
|
||
};
|
||
const defaultAngles = {
|
||
backrest_recline: twinAngleForPosition(
|
||
"backrest_recline",
|
||
TWIN_DEFAULT_POSITIONS.backrest_recline,
|
||
),
|
||
legrest_height: twinAngleForPosition(
|
||
"legrest_height",
|
||
TWIN_DEFAULT_POSITIONS.legrest_height,
|
||
),
|
||
};
|
||
|
||
return {
|
||
root,
|
||
slide,
|
||
cushionPivot,
|
||
backrestPivot,
|
||
legrestPivot,
|
||
legrestExtension,
|
||
anchors,
|
||
angleGuides,
|
||
outerSide,
|
||
trackedMaterials,
|
||
materials: {
|
||
shell,
|
||
insert,
|
||
thermalInsert,
|
||
bolster,
|
||
structure,
|
||
rail,
|
||
edge,
|
||
seam,
|
||
},
|
||
insertMaterial: thermalInsert,
|
||
isSelected: false,
|
||
thermalTargets: { ventilation: 0, heating: 0 },
|
||
thermalLevels: { ventilation: 0, heating: 0 },
|
||
thermalColors: {
|
||
base: new THREE.Color(0x9fb1b2),
|
||
ventilation: new THREE.Color(0x4b93b7),
|
||
heating: new THREE.Color(0xd05f43),
|
||
},
|
||
ventFields: [cushionVentField, backrestVentField],
|
||
heatPulses,
|
||
positions: { ...TWIN_DEFAULT_POSITIONS },
|
||
targets: { ...TWIN_DEFAULT_POSITIONS },
|
||
angles: { ...defaultAngles },
|
||
angleTargets: { ...defaultAngles },
|
||
};
|
||
}
|
||
|
||
function configureFixedTwinSeat(THREE, model, position, role) {
|
||
const cushionAngle = 6.5;
|
||
const backrestIncludedAngle = 104;
|
||
const legrestIncludedAngle = 95;
|
||
model.root.position.set(...position);
|
||
model.root.scale.setScalar(0.84);
|
||
model.root.userData.twinRole = role;
|
||
model.slide.position.z = 0;
|
||
model.cushionPivot.rotation.x = THREE.MathUtils.degToRad(-cushionAngle);
|
||
model.backrestPivot.rotation.x = (
|
||
model.cushionPivot.rotation.x
|
||
- THREE.MathUtils.degToRad(backrestIncludedAngle - 90)
|
||
);
|
||
model.legrestPivot.rotation.x = THREE.MathUtils.degToRad(
|
||
180 - cushionAngle - legrestIncludedAngle,
|
||
);
|
||
model.legrestExtension.position.z = 0;
|
||
Object.values(model.angleGuides).forEach((guide) => {
|
||
guide.group.visible = false;
|
||
});
|
||
model.ventFields.forEach((field) => {
|
||
field.points.visible = false;
|
||
});
|
||
model.heatPulses.forEach((pulse) => {
|
||
pulse.mesh.visible = false;
|
||
});
|
||
return model;
|
||
}
|
||
|
||
function setTwinSelection(selected) {
|
||
const twin = state.seatTwin;
|
||
if (!twin) return;
|
||
const focusedSeat = selected === "driver" ? "driver" : "passenger";
|
||
twin.selected = focusedSeat;
|
||
$("#seatTwin").dataset.selectedSeat = focusedSeat;
|
||
$("#seatTwinCanvas").dataset.activeSeat = focusedSeat === "driver" ? "front-left" : "front-right";
|
||
$$(".twin-seat-button").forEach((button) => {
|
||
const active = button.dataset.twinSeat === focusedSeat;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
});
|
||
Object.entries(twin.models).forEach(([seat, model]) => {
|
||
const isSelected = seat === focusedSeat;
|
||
model.isSelected = isSelected;
|
||
Object.entries(model.angleGuides).forEach(([axis, guide]) => {
|
||
guide.group.visible = (
|
||
isSelected
|
||
&& twin.explicitAngleTargets[focusedSeat]?.[axis] != null
|
||
);
|
||
});
|
||
model.trackedMaterials.forEach((material) => {
|
||
material.transparent = false;
|
||
material.opacity = 1;
|
||
material.depthWrite = true;
|
||
material.depthTest = true;
|
||
material.needsUpdate = true;
|
||
});
|
||
});
|
||
// Seat selection changes the highlighted actuator only. Keeping the camera
|
||
// centered on the vehicle prevents the default passenger selection from
|
||
// pushing the whole-car twin to the right.
|
||
twin.view.targetX = 0;
|
||
twin.view.yaw = -0.62;
|
||
renderTwinReadout();
|
||
}
|
||
|
||
function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
|
||
const device = actuatorState?.[seat] || {};
|
||
const position = device.position || {};
|
||
if (seat !== "driver") {
|
||
const seatHeight = position.height || position.cushion_tilt;
|
||
return {
|
||
...device,
|
||
position: {
|
||
...position,
|
||
// The seat-domain compatibility key is cushion_tilt, but the native
|
||
// CAN0 firmware axis 1 is physical height. Keep the 3D cushion neutral and
|
||
// animate/display the firmware feedback as height.
|
||
cushion_tilt: { position_pct: 0, position_valid: true },
|
||
height: seatHeight,
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
...device,
|
||
position: {
|
||
...position,
|
||
longitudinal: position.longitudinal || position.horizontal,
|
||
cushion_tilt: position.cushion_tilt || position.basin,
|
||
backrest_recline: position.backrest_recline || position.backrest,
|
||
},
|
||
};
|
||
}
|
||
|
||
function twinAxesForSeat(seat) {
|
||
return seat === "driver"
|
||
? new Set(["longitudinal", "height", "cushion_tilt", "backrest_recline"])
|
||
: new Set(["longitudinal", "height", "backrest_recline", "legrest_height"]);
|
||
}
|
||
|
||
function renderTwinReadout() {
|
||
const twin = state.seatTwin;
|
||
if (!twin) return;
|
||
const seat = twin.selected === "driver" ? "driver" : "passenger";
|
||
const device = twinDeviceForSeat(seat, twin.actuatorState);
|
||
const supportedAxes = twinAxesForSeat(seat);
|
||
const axisLabels = {
|
||
longitudinal: "水平位置", height: "座椅高度", cushion_tilt: "坐垫俯仰",
|
||
backrest_recline: "靠背夹角", legrest_height: "腿托夹角", legrest_extension: "腿托伸缩",
|
||
};
|
||
$("#twinSeatName").textContent = seat === "driver" ? "主驾座椅" : "副驾座椅";
|
||
$("#twinSeatZone").textContent = seat === "driver" ? "CH1 · 左前排" : "CH0 · 右前排";
|
||
const thermalRow = $(".twin-thermal-row");
|
||
if (thermalRow) thermalRow.hidden = seat === "passenger";
|
||
const postureAngles = twinPostureAngles(device);
|
||
Object.entries(TWIN_DEFAULT_POSITIONS).forEach(([axis, fallback]) => {
|
||
const motion = device?.position?.[axis];
|
||
const position = Number(motion?.position_pct ?? fallback);
|
||
const postureAngle = postureAngles[axis];
|
||
const estimatedPrefix = isEstimatedMotion(device, axis) ? "≈" : "";
|
||
const row = $(`[data-twin-axis="${axis}"]`);
|
||
if (!row) return;
|
||
row.hidden = !supportedAxes.has(axis);
|
||
if (axisLabels[axis]) $("span", row).textContent = axisLabels[axis];
|
||
$("b", row).textContent = postureAngle == null
|
||
? `${estimatedPrefix}${position}%`
|
||
: `${estimatedPrefix}${position}% · ${Number(postureAngle).toFixed(Number.isInteger(Number(postureAngle)) ? 0 : 1)}°`;
|
||
row.style.setProperty("--value", `${Math.max(0, Math.min(100, position))}%`);
|
||
row.classList.toggle("fault", Boolean(device?.position?.[axis]?.fault));
|
||
});
|
||
["ventilation", "heating"].forEach((feature) => {
|
||
const value = device?.[feature] || { state: "off", level: 0 };
|
||
const item = $(`[data-twin-function="${feature}"]`);
|
||
const enabled = value.state === "on";
|
||
const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0;
|
||
item.classList.toggle("on", enabled);
|
||
item.dataset.level = String(level);
|
||
$("b", item).textContent = enabled ? `${level}档` : "关闭";
|
||
});
|
||
}
|
||
|
||
function updateTwinExplicitAngles(result) {
|
||
const twin = state.seatTwin;
|
||
if (!twin) return;
|
||
const targets = { passenger: {}, driver: {} };
|
||
const requestedSeats = new Set();
|
||
const capabilityAxes = {
|
||
"seat.set_backrest_recline": "backrest_recline",
|
||
"seat.set_legrest_height": "legrest_height",
|
||
};
|
||
if (["succeeded", "partially_succeeded"].includes(result.status)) {
|
||
(result.model_requests || []).forEach((request) => {
|
||
const axis = capabilityAxes[request.capability];
|
||
const target = request.target_scope;
|
||
if (targets[target]) requestedSeats.add(target);
|
||
if (!axis || !targets[target]) return;
|
||
const parameter = (request.parameters || []).find(
|
||
(item) => item.name === "angle_deg" && Number.isFinite(Number(item.value)),
|
||
);
|
||
if (parameter) targets[target][axis] = Number(parameter.value);
|
||
});
|
||
}
|
||
twin.explicitAngleTargets = targets;
|
||
setTwinSelection(requestedSeats.has("driver") ? "driver" : twin.selected);
|
||
}
|
||
|
||
function updateSeatTwin(actuatorState) {
|
||
const twin = state.seatTwin;
|
||
if (!twin || !actuatorState) return;
|
||
twin.actuatorState = actuatorState;
|
||
Object.entries(twin.models).forEach(([seat, model]) => {
|
||
const device = twinDeviceForSeat(seat, actuatorState);
|
||
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
|
||
const motion = device?.position?.[axis];
|
||
const value = motion?.position_pct;
|
||
if (Number.isFinite(Number(value))) model.targets[axis] = Number(value);
|
||
if (TWIN_ANGLE_RANGES[axis]) {
|
||
const angle = motion?.angle_deg ?? twinAngleForPosition(axis, value);
|
||
if (Number.isFinite(Number(angle))) model.angleTargets[axis] = Number(angle);
|
||
}
|
||
});
|
||
["ventilation", "heating"].forEach((feature) => {
|
||
const value = device?.[feature];
|
||
const enabled = seat === "driver" && value?.state === "on";
|
||
const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0;
|
||
model.thermalTargets[feature] = level / 3;
|
||
});
|
||
});
|
||
renderTwinReadout();
|
||
}
|
||
|
||
const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/";
|
||
const REFERENCE_VEHICLE_ASSET_VERSION = "77a0fae7";
|
||
const REFERENCE_VEHICLE_LOAD_TIMEOUT_MS = 30000;
|
||
|
||
function referenceVehicleAssetUrl(fileName) {
|
||
return `${REFERENCE_VEHICLE_ROOT}${encodeURIComponent(fileName)}?v=${REFERENCE_VEHICLE_ASSET_VERSION}`;
|
||
}
|
||
|
||
const REFERENCE_VEHICLE_URL = referenceVehicleAssetUrl("byd-tang-2024.fbx");
|
||
|
||
function referenceMaterialDescriptor(material) {
|
||
const textureNames = [
|
||
"map",
|
||
"aoMap",
|
||
"alphaMap",
|
||
"normalMap",
|
||
"roughnessMap",
|
||
"metalnessMap",
|
||
"emissiveMap",
|
||
].map((key) => {
|
||
const image = material?.[key]?.image;
|
||
return image?.currentSrc || image?.src || image?.name || "";
|
||
});
|
||
return [material?.name || "", ...textureNames].join(" ").toLowerCase();
|
||
}
|
||
|
||
function classifyReferenceMaterial(material) {
|
||
const descriptor = referenceMaterialDescriptor(material);
|
||
if (/shadow/.test(descriptor)) return "shadow";
|
||
if (/tyre|tire|wheel|alloy|caliper/.test(descriptor)) return "wheel";
|
||
if (/interior|seat|dashboard|cockpit|steer/.test(descriptor)) return "interior";
|
||
if (/(^|[_\W])(hl|tl|fl)([_\W]|$)|headlight|taillight|lamp|light|fl_emiss|rear_emiss/.test(descriptor)) return "light";
|
||
if (/glass|window|windshield/.test(descriptor)) return "glass";
|
||
if (/body|paint|carpaint/.test(descriptor)) return "body";
|
||
return "detail";
|
||
}
|
||
|
||
const TWIN_OUTLINE_STYLE = {
|
||
body: {
|
||
color: 0x2a6fb8,
|
||
lineOpacity: 0.9,
|
||
occludedColor: 0xa9bfd7,
|
||
occludedLineOpacity: 0.13,
|
||
fillOpacity: 0.3,
|
||
thresholdAngle: 38,
|
||
renderOrder: 30,
|
||
},
|
||
glass: {
|
||
color: 0x4d83bd,
|
||
lineOpacity: 0.76,
|
||
occludedColor: 0xa5b8bb,
|
||
occludedLineOpacity: 0.08,
|
||
fillOpacity: 0.16,
|
||
thresholdAngle: 38,
|
||
renderOrder: 31,
|
||
},
|
||
detail: {
|
||
color: 0x5a7695,
|
||
lineOpacity: 0.5,
|
||
occludedColor: 0xa8b7b9,
|
||
occludedLineOpacity: 0.05,
|
||
fillOpacity: 0.1,
|
||
thresholdAngle: 46,
|
||
renderOrder: 32,
|
||
},
|
||
wheel: {
|
||
color: 0x284665,
|
||
lineOpacity: 0.84,
|
||
occludedColor: 0x9eafb2,
|
||
occludedLineOpacity: 0.14,
|
||
fillOpacity: 0.3,
|
||
thresholdAngle: 50,
|
||
renderOrder: 33,
|
||
},
|
||
interior: { color: 0x667f99, lineOpacity: 0, fillOpacity: 0.028, renderOrder: 36 },
|
||
light: { color: 0x5a7898, lineOpacity: 0, fillOpacity: 0.12, renderOrder: 32 },
|
||
};
|
||
|
||
function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) {
|
||
if (
|
||
!mesh.geometry?.attributes?.position
|
||
|| mesh.userData.twinOutline
|
||
|| style.lineOpacity <= 0
|
||
) return null;
|
||
const geometry = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle);
|
||
if (!geometry.getAttribute("position")?.count) {
|
||
geometry.dispose();
|
||
return null;
|
||
}
|
||
const visibleMaterial = new THREE.LineBasicMaterial({
|
||
color: style.color,
|
||
transparent: true,
|
||
opacity: style.lineOpacity,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
depthFunc: THREE.LessEqualDepth,
|
||
toneMapped: false,
|
||
});
|
||
visibleMaterial.onBeforeCompile = (shader) => {
|
||
shader.vertexShader = shader.vertexShader.replace(
|
||
"#include <project_vertex>",
|
||
"#include <project_vertex>\ngl_Position.z -= 0.0006 * gl_Position.w;",
|
||
);
|
||
};
|
||
visibleMaterial.customProgramCacheKey = () => "twin-visible-line-depth-bias-v1";
|
||
const visibleOutline = new THREE.LineSegments(geometry, visibleMaterial);
|
||
visibleOutline.name = `${mesh.name || "mesh"}_outline_visible`;
|
||
visibleOutline.renderOrder = style.renderOrder + 1;
|
||
visibleOutline.frustumCulled = mesh.frustumCulled;
|
||
mesh.add(visibleOutline);
|
||
mesh.userData.twinOutline = visibleOutline;
|
||
|
||
const occludedOpacity = style.occludedLineOpacity ?? 0;
|
||
if (occludedOpacity > 0) {
|
||
const occludedMaterial = new THREE.LineBasicMaterial({
|
||
color: style.occludedColor ?? style.color,
|
||
transparent: true,
|
||
opacity: occludedOpacity,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
depthFunc: THREE.GreaterDepth,
|
||
toneMapped: false,
|
||
});
|
||
const occludedOutline = new THREE.LineSegments(geometry, occludedMaterial);
|
||
occludedOutline.name = `${mesh.name || "mesh"}_outline_occluded`;
|
||
occludedOutline.renderOrder = style.renderOrder;
|
||
occludedOutline.frustumCulled = mesh.frustumCulled;
|
||
mesh.add(occludedOutline);
|
||
mesh.userData.twinOccludedOutline = occludedOutline;
|
||
}
|
||
return visibleOutline;
|
||
}
|
||
|
||
function applyTwinOutlineStyle(THREE, root, options = {}) {
|
||
const style = {
|
||
color: options.color ?? 0x52727a,
|
||
lineOpacity: options.lineOpacity ?? 0.58,
|
||
fillOpacity: options.fillOpacity ?? 0.014,
|
||
renderOrder: options.renderOrder ?? 40,
|
||
};
|
||
const meshes = [];
|
||
root.traverse((object) => {
|
||
if (object.isMesh && object.visible) meshes.push(object);
|
||
});
|
||
meshes.forEach((mesh) => {
|
||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||
materials.filter(Boolean).forEach((material) => {
|
||
material.transparent = true;
|
||
material.opacity = Math.min(Number(material.opacity ?? 1), style.fillOpacity);
|
||
material.depthWrite = false;
|
||
material.side = THREE.DoubleSide;
|
||
material.needsUpdate = true;
|
||
});
|
||
mesh.castShadow = false;
|
||
mesh.receiveShadow = false;
|
||
mesh.renderOrder = style.renderOrder - 1;
|
||
addTwinMeshOutline(THREE, mesh, style, options.thresholdAngle ?? 28);
|
||
});
|
||
}
|
||
|
||
function configureTwinSolidEntity(THREE, roots, options = {}) {
|
||
const items = Array.isArray(roots) ? roots : [roots];
|
||
items.filter(Boolean).forEach((root) => {
|
||
root.traverse((object) => {
|
||
if (!object.isMesh || !object.visible) return;
|
||
if (object.userData.twinOutline) object.userData.twinOutline.visible = false;
|
||
if (object.userData.twinOccludedOutline) {
|
||
object.userData.twinOccludedOutline.visible = false;
|
||
}
|
||
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||
materials.filter(Boolean).forEach((material) => {
|
||
material.transparent = Boolean(options.transparent);
|
||
material.opacity = options.opacity ?? 1;
|
||
material.depthWrite = options.depthWrite ?? !options.transparent;
|
||
material.side = THREE.DoubleSide;
|
||
material.needsUpdate = true;
|
||
});
|
||
object.castShadow = true;
|
||
object.receiveShadow = true;
|
||
object.renderOrder = options.renderOrder ?? 46;
|
||
});
|
||
});
|
||
}
|
||
|
||
function configureActivePassengerSeatStyle(model) {
|
||
const {
|
||
shell,
|
||
insert,
|
||
thermalInsert,
|
||
bolster,
|
||
structure,
|
||
rail,
|
||
edge,
|
||
seam,
|
||
} = model.materials;
|
||
shell.color.setHex(0xd85b27);
|
||
shell.roughness = 0.3;
|
||
shell.clearcoat = 0.58;
|
||
shell.emissive.setHex(0x64210c);
|
||
shell.emissiveIntensity = 0.24;
|
||
insert.color.setHex(0xffe0a3);
|
||
insert.emissive.setHex(0x6d4315);
|
||
insert.emissiveIntensity = 0.2;
|
||
thermalInsert.color.setHex(0xffc866);
|
||
thermalInsert.emissive.setHex(0x7a3d0d);
|
||
thermalInsert.emissiveIntensity = 0.24;
|
||
bolster.color.setHex(0xb93426);
|
||
bolster.emissive.setHex(0x53120d);
|
||
bolster.emissiveIntensity = 0.2;
|
||
structure.color.setHex(0x39231d);
|
||
structure.emissive.setHex(0x1b0d09);
|
||
structure.emissiveIntensity = 0.14;
|
||
rail.color.setHex(0xa8bdc5);
|
||
rail.metalness = 0.82;
|
||
[shell, insert, thermalInsert, bolster, structure, rail].forEach((material) => {
|
||
material.transparent = false;
|
||
material.opacity = 1;
|
||
material.depthWrite = true;
|
||
material.depthTest = true;
|
||
material.alphaTest = 0;
|
||
material.colorWrite = true;
|
||
material.toneMapped = false;
|
||
if ("transmission" in material) material.transmission = 0;
|
||
material.needsUpdate = true;
|
||
});
|
||
edge.color.setHex(0xffd592);
|
||
edge.opacity = 1;
|
||
edge.depthTest = true;
|
||
edge.depthWrite = false;
|
||
seam.color.setHex(0xfff1cd);
|
||
seam.opacity = 1;
|
||
seam.depthTest = true;
|
||
seam.depthWrite = false;
|
||
model.thermalColors.base.setHex(0xffc866);
|
||
model.root.traverse((object) => {
|
||
if (!object.isLine && !object.isLineSegments) return;
|
||
object.renderOrder = 53;
|
||
});
|
||
}
|
||
|
||
function configureActiveSeatDepthLayers(THREE, model) {
|
||
const seatMeshes = [];
|
||
model.root.traverse((object) => {
|
||
if (object.isMesh && object.visible) seatMeshes.push(object);
|
||
});
|
||
seatMeshes.forEach((object) => {
|
||
object.renderOrder = 51;
|
||
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
|
||
const ghostMaterials = sourceMaterials.map((source) => new THREE.MeshBasicMaterial({
|
||
color: source?.color?.getHex?.() ?? 0xd98b55,
|
||
transparent: true,
|
||
opacity: 0.18,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
depthFunc: THREE.GreaterDepth,
|
||
side: THREE.DoubleSide,
|
||
toneMapped: false,
|
||
}));
|
||
const ghost = new THREE.Mesh(
|
||
object.geometry,
|
||
Array.isArray(object.material) ? ghostMaterials : ghostMaterials[0],
|
||
);
|
||
ghost.name = `${object.name || "seat"}_occluded_ghost`;
|
||
ghost.userData = { twinRole: "active_seat_occluded_ghost" };
|
||
ghost.renderOrder = 52;
|
||
ghost.castShadow = false;
|
||
ghost.receiveShadow = false;
|
||
object.add(ghost);
|
||
object.userData.twinOccludedGhost = ghost;
|
||
addTwinMeshOutline(THREE, object, {
|
||
color: 0xffd592,
|
||
lineOpacity: 0.54,
|
||
occludedColor: 0xd98b55,
|
||
occludedLineOpacity: 0.14,
|
||
fillOpacity: 0,
|
||
renderOrder: 52,
|
||
}, 28);
|
||
});
|
||
}
|
||
|
||
function addTwinGlassDepthMask(THREE, glassMesh) {
|
||
if (!glassMesh?.geometry || glassMesh.userData.twinDepthMask) return null;
|
||
const material = new THREE.MeshBasicMaterial({
|
||
colorWrite: false,
|
||
depthWrite: true,
|
||
depthTest: true,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
const mask = new THREE.Mesh(glassMesh.geometry, material);
|
||
mask.name = `${glassMesh.name || "glass"}_depth_mask`;
|
||
mask.userData = { twinRole: "glass_depth_mask" };
|
||
mask.renderOrder = 48;
|
||
mask.castShadow = false;
|
||
mask.receiveShadow = false;
|
||
glassMesh.add(mask);
|
||
glassMesh.userData.twinDepthMask = mask;
|
||
return mask;
|
||
}
|
||
|
||
function visibleTwinMeshBounds(THREE, root) {
|
||
const bounds = new THREE.Box3();
|
||
const meshBounds = new THREE.Box3();
|
||
root.updateWorldMatrix(true, true);
|
||
root.traverse((object) => {
|
||
if (!object.isMesh || !object.visible || !object.geometry?.attributes?.position) return;
|
||
if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
|
||
meshBounds.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld);
|
||
bounds.union(meshBounds);
|
||
});
|
||
return bounds;
|
||
}
|
||
|
||
function twinObjectCollectionCenter(THREE, objects, target) {
|
||
const bounds = new THREE.Box3();
|
||
const objectBounds = new THREE.Box3();
|
||
let found = false;
|
||
objects.filter(Boolean).forEach((item) => {
|
||
const object = item.mesh || item;
|
||
if (!object?.isObject3D || !object.visible) return;
|
||
object.updateWorldMatrix(true, false);
|
||
objectBounds.setFromObject(object);
|
||
if (objectBounds.isEmpty()) return;
|
||
bounds.union(objectBounds);
|
||
found = true;
|
||
});
|
||
if (!found || bounds.isEmpty()) return false;
|
||
bounds.getCenter(target);
|
||
return true;
|
||
}
|
||
|
||
function twinDeviceWorldAnchor(THREE, twin, deviceId, target) {
|
||
const layout = TWIN_DEVICE_HOTSPOT_LAYOUT[deviceId];
|
||
if (!layout) return false;
|
||
|
||
if (["driver", "passenger"].includes(deviceId)) {
|
||
const axis = deviceId === "driver" ? "backrest_recline" : "cushion_tilt";
|
||
const seatAnchor = twin.models?.[deviceId]?.anchors?.[axis];
|
||
if (seatAnchor) {
|
||
seatAnchor.getWorldPosition(target);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
if (deviceId === "shade" && twin.vehicleSystems?.roofGlass) {
|
||
twin.vehicleSystems.roofGlass.getWorldPosition(target);
|
||
return true;
|
||
}
|
||
|
||
if (deviceId === "windows") {
|
||
const nearSide = twin.camera.position.x < 0 ? "right" : "left";
|
||
const panes = [
|
||
...(twin.vehicleSystems?.referenceWindowPanes?.[`front_${nearSide}`] || []),
|
||
...(twin.vehicleSystems?.referenceWindowPanes?.[`rear_${nearSide}`] || []),
|
||
];
|
||
if (twinObjectCollectionCenter(THREE, panes, target)) return true;
|
||
}
|
||
|
||
if (deviceId === "ambient") {
|
||
const ambientMeshes = twin.vehicleSystems?.ambientMeshes || [];
|
||
if (twinObjectCollectionCenter(THREE, ambientMeshes, target)) return true;
|
||
}
|
||
|
||
if (deviceId === "climate") {
|
||
const dashboardVents = (twin.vehicleSystems?.ventMeshes || []).slice(0, 4);
|
||
if (twinObjectCollectionCenter(THREE, dashboardVents, target)) return true;
|
||
}
|
||
|
||
if (deviceId === "headlight") {
|
||
const lamps = twin.vehicleSystems?.headlampMeshes || [];
|
||
const nearLamp = twin.camera.position.x < 0 ? lamps[0] : lamps[lamps.length - 1];
|
||
if (nearLamp) {
|
||
nearLamp.getWorldPosition(target);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const bounds = twin.referenceBounds?.isEmpty?.() === false
|
||
? twin.referenceBounds
|
||
: visibleTwinMeshBounds(THREE, twin.vehicleSystems.root);
|
||
if (!bounds || bounds.isEmpty()) return false;
|
||
const size = bounds.getSize(new THREE.Vector3());
|
||
const [x, y, z] = layout.normalized;
|
||
target.set(
|
||
bounds.min.x + size.x * x,
|
||
bounds.min.y + size.y * y,
|
||
bounds.min.z + size.z * z,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
function configureReferenceVehicleModel(THREE, vehicle) {
|
||
const palette = currentTwinThemePalette();
|
||
const materialCache = new Map();
|
||
const report = { meshes: 0, materials: new Map(), nodes: [] };
|
||
vehicle.traverse((object) => {
|
||
if (object.name) report.nodes.push(object.name);
|
||
if (!object.isMesh) return;
|
||
report.meshes += 1;
|
||
object.castShadow = false;
|
||
object.receiveShadow = false;
|
||
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
|
||
const configuredMaterials = sourceMaterials.map((sourceMaterial) => {
|
||
if (!sourceMaterial) return sourceMaterial;
|
||
if (materialCache.has(sourceMaterial)) return materialCache.get(sourceMaterial);
|
||
const material = sourceMaterial.clone();
|
||
const descriptor = referenceMaterialDescriptor(material);
|
||
const category = classifyReferenceMaterial(material);
|
||
let themeToken = "vehicleDetail";
|
||
const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail;
|
||
const key = `${category}:${material.name || "unnamed"}`;
|
||
report.materials.set(key, (report.materials.get(key) || 0) + 1);
|
||
[
|
||
"map",
|
||
"aoMap",
|
||
"bumpMap",
|
||
"normalMap",
|
||
"roughnessMap",
|
||
"metalnessMap",
|
||
"emissiveMap",
|
||
].forEach((textureKey) => {
|
||
if (textureKey in material) material[textureKey] = null;
|
||
});
|
||
if (material.emissive) {
|
||
material.emissive.setHex(0x000000);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
|
||
}
|
||
if (category === "glass") {
|
||
themeToken = "glass";
|
||
if (material.color) material.color.setHex(palette.glass);
|
||
material.transparent = true;
|
||
material.opacity = 0.3;
|
||
material.depthWrite = false;
|
||
material.side = THREE.DoubleSide;
|
||
object.castShadow = false;
|
||
object.renderOrder = 12;
|
||
} else if (category === "body") {
|
||
themeToken = "vehicleBody";
|
||
if (material.color) material.color.setHex(palette.vehicleBody);
|
||
if ("shininess" in material) material.shininess = 72;
|
||
} else if (category === "light") {
|
||
themeToken = "lamp";
|
||
material.map = null;
|
||
if (material.color) material.color.setHex(palette.lamp);
|
||
if (material.emissive) material.emissive.setHex(0x000000);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
|
||
} else if (category === "interior" && material.color) {
|
||
themeToken = "vehicleInterior";
|
||
material.color.setHex(palette.vehicleInterior);
|
||
} else if (/lines|\badd\b/.test(descriptor) && material.color) {
|
||
themeToken = "vehicleTrim";
|
||
material.map = null;
|
||
material.color.setHex(palette.vehicleTrim);
|
||
} else if (/map[_ -]?[abc]/.test(descriptor) && material.color) {
|
||
if (/chrome|metal/.test(descriptor)) {
|
||
themeToken = "vehicleMetal";
|
||
material.color.setHex(palette.vehicleMetal);
|
||
} else if (/plastic/.test(descriptor)) {
|
||
themeToken = "vehicleTrim";
|
||
material.color.setHex(palette.vehicleTrim);
|
||
} else {
|
||
material.color.setHex(palette.vehicleDetail);
|
||
}
|
||
} else if (/plastic|under|black/.test(descriptor) && material.color) {
|
||
themeToken = "vehicleTrim";
|
||
material.color.setHex(palette.vehicleTrim);
|
||
} else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) {
|
||
themeToken = "vehicleMetal";
|
||
material.color.setHex(palette.vehicleMetal);
|
||
}
|
||
material.transparent = true;
|
||
material.opacity = outlineStyle.fillOpacity;
|
||
material.depthWrite = ["body", "detail", "wheel"].includes(category);
|
||
material.depthTest = true;
|
||
material.side = category === "glass" ? THREE.DoubleSide : THREE.FrontSide;
|
||
material.wireframe = false;
|
||
if ("flatShading" in material) material.flatShading = false;
|
||
if ("roughness" in material) material.roughness = category === "body" ? 0.72 : 0.82;
|
||
if ("metalness" in material) material.metalness = category === "wheel" ? 0.48 : 0.08;
|
||
if (material.emissive) material.emissive.setHex(0x000000);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
|
||
material.userData = { ...(material.userData || {}), twinThemeToken: themeToken };
|
||
material.needsUpdate = true;
|
||
materialCache.set(sourceMaterial, material);
|
||
return material;
|
||
});
|
||
object.material = Array.isArray(object.material) ? configuredMaterials : configuredMaterials[0];
|
||
const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial);
|
||
if (categories.length && categories.every((category) => category === "shadow")) object.visible = false;
|
||
if (!object.visible) return;
|
||
const categoryPriority = ["body", "glass", "wheel", "light", "detail", "interior"];
|
||
const outlineCategory = categoryPriority.find((category) => categories.includes(category)) || "detail";
|
||
const outlineStyle = TWIN_OUTLINE_STYLE[outlineCategory];
|
||
object.renderOrder = outlineStyle.renderOrder - 1;
|
||
const isControllableWindowPane = String(object.userData.twinRole || "")
|
||
.startsWith("reference_window_");
|
||
if (!isControllableWindowPane) {
|
||
addTwinMeshOutline(
|
||
THREE,
|
||
object,
|
||
outlineStyle,
|
||
outlineStyle.thresholdAngle ?? 60,
|
||
);
|
||
}
|
||
});
|
||
return report;
|
||
}
|
||
|
||
function normalizeReferenceVehicle(THREE, vehicle) {
|
||
vehicle.updateMatrixWorld(true);
|
||
let bounds = new THREE.Box3().setFromObject(vehicle);
|
||
let size = bounds.getSize(new THREE.Vector3());
|
||
if (size.x > size.z) {
|
||
vehicle.rotateY(Math.PI / 2);
|
||
vehicle.updateMatrixWorld(true);
|
||
bounds = new THREE.Box3().setFromObject(vehicle);
|
||
size = bounds.getSize(new THREE.Vector3());
|
||
}
|
||
const scale = 7.45 / Math.max(size.x, size.z);
|
||
vehicle.scale.multiplyScalar(scale);
|
||
vehicle.updateMatrixWorld(true);
|
||
bounds = new THREE.Box3().setFromObject(vehicle);
|
||
const center = bounds.getCenter(new THREE.Vector3());
|
||
vehicle.position.x -= center.x;
|
||
vehicle.position.z -= center.z;
|
||
vehicle.position.y += -0.145 - bounds.min.y;
|
||
vehicle.updateMatrixWorld(true);
|
||
bounds = new THREE.Box3().setFromObject(vehicle);
|
||
return { bounds, size: bounds.getSize(new THREE.Vector3()), scale };
|
||
}
|
||
|
||
function measureReferenceSeatCushion(THREE, vehicle, minX, maxX) {
|
||
const interior = vehicle.getObjectByName("Interior");
|
||
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return null;
|
||
interior.updateWorldMatrix(true, false);
|
||
const geometry = interior.geometry.index
|
||
? interior.geometry.toNonIndexed()
|
||
: interior.geometry;
|
||
const positions = geometry.getAttribute("position");
|
||
const centroid = new THREE.Vector3();
|
||
const vertex = new THREE.Vector3();
|
||
const samples = [];
|
||
for (let offset = 0; offset < positions.count; offset += 3) {
|
||
centroid.set(0, 0, 0);
|
||
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
|
||
vertex.fromBufferAttribute(positions, offset + vertexOffset);
|
||
vertex.applyMatrix4(interior.matrixWorld);
|
||
centroid.add(vertex);
|
||
}
|
||
centroid.multiplyScalar(1 / 3);
|
||
const insideSeatCushion = (
|
||
centroid.x >= minX
|
||
&& centroid.x <= maxX
|
||
&& centroid.y >= 0.25
|
||
&& centroid.y <= 1.15
|
||
&& centroid.z >= -0.12
|
||
&& centroid.z <= 0.98
|
||
);
|
||
if (insideSeatCushion) samples.push(centroid.y);
|
||
}
|
||
if (geometry !== interior.geometry) geometry.dispose();
|
||
if (samples.length < 20) return null;
|
||
samples.sort((left, right) => left - right);
|
||
const quantile = (ratio) => samples[Math.min(
|
||
samples.length - 1,
|
||
Math.floor((samples.length - 1) * ratio),
|
||
)];
|
||
return {
|
||
triangleCount: samples.length,
|
||
medianY: quantile(0.5),
|
||
upperQuartileY: quantile(0.75),
|
||
surfaceY: quantile(0.9),
|
||
};
|
||
}
|
||
|
||
function measureReferenceDriverCushion(THREE, vehicle) {
|
||
return measureReferenceSeatCushion(THREE, vehicle, 0.18, 1.18);
|
||
}
|
||
|
||
function measureReferencePassengerCushion(THREE, vehicle) {
|
||
return measureReferenceSeatCushion(THREE, vehicle, -1.18, -0.18);
|
||
}
|
||
|
||
function carveReferenceFrontSeats(THREE, vehicle) {
|
||
const interior = vehicle.getObjectByName("Interior");
|
||
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return 0;
|
||
interior.updateWorldMatrix(true, false);
|
||
const source = interior.geometry.index
|
||
? interior.geometry.toNonIndexed()
|
||
: interior.geometry.clone();
|
||
const positions = source.getAttribute("position");
|
||
const centroid = new THREE.Vector3();
|
||
const vertex = new THREE.Vector3();
|
||
const keptVertices = [];
|
||
let removedTriangles = 0;
|
||
for (let offset = 0; offset < positions.count; offset += 3) {
|
||
centroid.set(0, 0, 0);
|
||
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
|
||
vertex.fromBufferAttribute(positions, offset + vertexOffset);
|
||
vertex.applyMatrix4(interior.matrixWorld);
|
||
centroid.add(vertex);
|
||
}
|
||
centroid.multiplyScalar(1 / 3);
|
||
const insideFrontSeat = (
|
||
((centroid.x >= -1.15 && centroid.x <= -0.12)
|
||
|| (centroid.x >= 0.12 && centroid.x <= 1.15))
|
||
&& centroid.y >= 0.05
|
||
&& centroid.y <= 2.05
|
||
&& centroid.z >= -0.92
|
||
&& centroid.z <= 1.08
|
||
);
|
||
if (insideFrontSeat) {
|
||
removedTriangles += 1;
|
||
continue;
|
||
}
|
||
keptVertices.push(offset, offset + 1, offset + 2);
|
||
}
|
||
if (removedTriangles < 40) {
|
||
source.dispose();
|
||
return 0;
|
||
}
|
||
const carved = new THREE.BufferGeometry();
|
||
Object.entries(source.attributes).forEach(([name, attribute]) => {
|
||
const values = new attribute.array.constructor(keptVertices.length * attribute.itemSize);
|
||
keptVertices.forEach((sourceIndex, targetIndex) => {
|
||
for (let component = 0; component < attribute.itemSize; component += 1) {
|
||
values[targetIndex * attribute.itemSize + component] = (
|
||
attribute.array[sourceIndex * attribute.itemSize + component]
|
||
);
|
||
}
|
||
});
|
||
carved.setAttribute(
|
||
name,
|
||
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
|
||
);
|
||
});
|
||
carved.computeBoundingBox();
|
||
carved.computeBoundingSphere();
|
||
interior.geometry = carved;
|
||
source.dispose();
|
||
return removedTriangles;
|
||
}
|
||
|
||
function makeTriangleSubsetGeometry(THREE, source, vertexIndices) {
|
||
const subset = new THREE.BufferGeometry();
|
||
Object.entries(source.attributes).forEach(([name, attribute]) => {
|
||
const values = new attribute.array.constructor(vertexIndices.length * attribute.itemSize);
|
||
vertexIndices.forEach((sourceIndex, targetIndex) => {
|
||
for (let component = 0; component < attribute.itemSize; component += 1) {
|
||
values[targetIndex * attribute.itemSize + component] = (
|
||
attribute.array[sourceIndex * attribute.itemSize + component]
|
||
);
|
||
}
|
||
});
|
||
subset.setAttribute(
|
||
name,
|
||
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
|
||
);
|
||
});
|
||
subset.computeBoundingBox();
|
||
subset.computeBoundingSphere();
|
||
return subset;
|
||
}
|
||
|
||
function extractReferenceWindowPanes(THREE, vehicle) {
|
||
const panes = {
|
||
front_left: [],
|
||
front_right: [],
|
||
rear_left: [],
|
||
rear_right: [],
|
||
};
|
||
vehicle.updateMatrixWorld(true);
|
||
const sources = [];
|
||
vehicle.traverse((object) => {
|
||
if (object.isMesh && /^(window_gls|black_glasses)$/i.test(object.name || "")) {
|
||
sources.push(object);
|
||
}
|
||
});
|
||
|
||
sources.forEach((object) => {
|
||
const originalGeometry = object.geometry;
|
||
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
|
||
const positions = source.getAttribute("position");
|
||
const retainedVertices = [];
|
||
const paneVertices = Object.fromEntries(Object.keys(panes).map((key) => [key, []]));
|
||
const a = new THREE.Vector3();
|
||
const b = new THREE.Vector3();
|
||
const c = new THREE.Vector3();
|
||
const centroid = new THREE.Vector3();
|
||
const normal = new THREE.Vector3();
|
||
const edge = new THREE.Vector3();
|
||
const rearCandidates = [];
|
||
const pointKey = (point) => [point.x, point.y, point.z]
|
||
.map((value) => Math.round(value * 10000))
|
||
.join(":");
|
||
const edgeKey = (start, end) => [start, end].sort().join("|");
|
||
|
||
for (let index = 0; index < positions.count; index += 3) {
|
||
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
|
||
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
|
||
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
|
||
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
|
||
normal.copy(b).sub(a).cross(edge.copy(c).sub(a)).normalize();
|
||
const isSideWindow = (
|
||
Math.abs(normal.x) >= 0.48
|
||
&& Math.abs(normal.x) >= Math.abs(normal.z) * 1.2
|
||
&& Math.abs(centroid.x) >= 0.58
|
||
&& centroid.y >= 1.42
|
||
);
|
||
if (!isSideWindow) {
|
||
retainedVertices.push(index, index + 1, index + 2);
|
||
continue;
|
||
}
|
||
// The reference model uses positive X for the driver/vehicle-left side.
|
||
const side = centroid.x < 0 ? "right" : "left";
|
||
if (/^window_gls$/i.test(object.name || "")) {
|
||
paneVertices[`front_${side}`].push(index, index + 1, index + 2);
|
||
continue;
|
||
}
|
||
const vertexKeys = [pointKey(a), pointKey(b), pointKey(c)];
|
||
rearCandidates.push({
|
||
indices: [index, index + 1, index + 2],
|
||
side,
|
||
maxZ: Math.max(a.z, b.z, c.z),
|
||
edgeKeys: [
|
||
edgeKey(vertexKeys[0], vertexKeys[1]),
|
||
edgeKey(vertexKeys[1], vertexKeys[2]),
|
||
edgeKey(vertexKeys[2], vertexKeys[0]),
|
||
],
|
||
});
|
||
}
|
||
|
||
if (rearCandidates.length) {
|
||
const parents = rearCandidates.map((_, index) => index);
|
||
const find = (index) => {
|
||
let root = index;
|
||
while (parents[root] !== root) root = parents[root];
|
||
while (parents[index] !== index) {
|
||
const next = parents[index];
|
||
parents[index] = root;
|
||
index = next;
|
||
}
|
||
return root;
|
||
};
|
||
const unite = (left, right) => {
|
||
const leftRoot = find(left);
|
||
const rightRoot = find(right);
|
||
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
|
||
};
|
||
const edgeOwners = new Map();
|
||
rearCandidates.forEach((candidate, candidateIndex) => {
|
||
candidate.edgeKeys.forEach((key) => {
|
||
if (edgeOwners.has(key)) unite(candidateIndex, edgeOwners.get(key));
|
||
else edgeOwners.set(key, candidateIndex);
|
||
});
|
||
});
|
||
|
||
const components = new Map();
|
||
rearCandidates.forEach((candidate, candidateIndex) => {
|
||
const root = find(candidateIndex);
|
||
if (!components.has(root)) {
|
||
components.set(root, { side: candidate.side, maxZ: candidate.maxZ, candidates: [] });
|
||
}
|
||
const component = components.get(root);
|
||
component.maxZ = Math.max(component.maxZ, candidate.maxZ);
|
||
component.candidates.push(candidate);
|
||
});
|
||
const leadingMaxZ = { left: -Infinity, right: -Infinity };
|
||
components.forEach((component) => {
|
||
leadingMaxZ[component.side] = Math.max(leadingMaxZ[component.side], component.maxZ);
|
||
});
|
||
components.forEach((component) => {
|
||
const isRearDoorWindow = component.maxZ >= leadingMaxZ[component.side] - 0.38;
|
||
component.candidates.forEach((candidate) => {
|
||
if (isRearDoorWindow) paneVertices[`rear_${candidate.side}`].push(...candidate.indices);
|
||
else retainedVertices.push(...candidate.indices);
|
||
});
|
||
});
|
||
}
|
||
|
||
const retainedGeometry = makeTriangleSubsetGeometry(THREE, source, retainedVertices);
|
||
object.geometry = retainedGeometry;
|
||
Object.entries(paneVertices).forEach(([key, vertexIndices]) => {
|
||
if (!vertexIndices.length) return;
|
||
const pane = object.clone(false);
|
||
pane.name = `${object.name}_${key}`;
|
||
pane.geometry = makeTriangleSubsetGeometry(THREE, source, vertexIndices);
|
||
pane.userData = { ...object.userData, twinRole: `reference_window_${key}` };
|
||
object.parent.add(pane);
|
||
panes[key].push({ mesh: pane });
|
||
});
|
||
originalGeometry.dispose();
|
||
if (source !== originalGeometry) source.dispose();
|
||
});
|
||
|
||
vehicle.updateMatrixWorld(true);
|
||
Object.values(panes).flat().forEach((pane) => {
|
||
const closedPosition = pane.mesh.position.clone();
|
||
const openWorldPosition = pane.mesh.getWorldPosition(new THREE.Vector3());
|
||
openWorldPosition.y -= 0.86;
|
||
const openPosition = pane.mesh.parent.worldToLocal(openWorldPosition);
|
||
pane.closedPosition = closedPosition;
|
||
pane.openPosition = openPosition;
|
||
});
|
||
return panes;
|
||
}
|
||
|
||
function referenceFrontLampMaterialIndexes(mesh) {
|
||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||
return materials.flatMap((material, index) => {
|
||
if (!material) return [];
|
||
const materialName = String(material.name || "").trim();
|
||
return /^fl[_ -]?light$/i.test(materialName) ? [index] : [];
|
||
});
|
||
}
|
||
|
||
function collectReferenceFrontLampMeshes(vehicle) {
|
||
const meshes = [];
|
||
vehicle.traverse((object) => {
|
||
if (!object.isMesh) return;
|
||
if (referenceFrontLampMaterialIndexes(object).length) meshes.push(object);
|
||
});
|
||
return meshes;
|
||
}
|
||
|
||
function emphasizeReferenceWindowPanes(THREE, panes) {
|
||
Object.values(panes).flat().forEach((pane) => {
|
||
const materials = Array.isArray(pane.mesh.material) ? pane.mesh.material : [pane.mesh.material];
|
||
const emphasizedMaterials = materials.filter(Boolean).map((source) => {
|
||
const material = source.clone();
|
||
if (material.color) material.color.setHex(0x4b9bea);
|
||
if (material.emissive) material.emissive.setHex(0x164f91);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.38;
|
||
material.transparent = true;
|
||
material.opacity = 0.6;
|
||
material.depthWrite = false;
|
||
material.depthTest = true;
|
||
material.side = THREE.DoubleSide;
|
||
material.toneMapped = false;
|
||
material.needsUpdate = true;
|
||
return material;
|
||
});
|
||
pane.mesh.material = Array.isArray(pane.mesh.material)
|
||
? emphasizedMaterials
|
||
: emphasizedMaterials[0];
|
||
pane.mesh.renderOrder = 58;
|
||
pane.materials = emphasizedMaterials;
|
||
pane.outline = addTwinMeshOutline(THREE, pane.mesh, {
|
||
color: 0x1b6fc4,
|
||
lineOpacity: 0.98,
|
||
fillOpacity: 0.6,
|
||
renderOrder: 59,
|
||
}, 22);
|
||
pane.depthMask = addTwinGlassDepthMask(THREE, pane.mesh);
|
||
});
|
||
}
|
||
|
||
function updateReferenceWindowOutlineLayers(systems, camera) {
|
||
if (!systems || !camera) return;
|
||
const nearSide = camera.position.x < 0 ? "right" : "left";
|
||
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
|
||
const isForegroundWindow = key.endsWith(`_${nearSide}`);
|
||
panes.forEach((pane) => {
|
||
if (!pane.outline?.material) return;
|
||
pane.outline.material.depthTest = !isForegroundWindow;
|
||
pane.outline.material.depthWrite = false;
|
||
pane.outline.renderOrder = isForegroundWindow ? 66 : 60;
|
||
});
|
||
});
|
||
}
|
||
|
||
function styleReferencePassiveGlass(THREE, vehicle) {
|
||
vehicle.traverse((object) => {
|
||
if (!object.isMesh) return;
|
||
if (String(object.userData.twinRole || "").startsWith("reference_window_")) return;
|
||
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
|
||
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
|
||
if (!categories.includes("glass")) return;
|
||
const neutralMaterials = sourceMaterials.map((source) => {
|
||
if (!source) return source;
|
||
if (classifyReferenceMaterial(source) !== "glass") return source;
|
||
const material = source.clone();
|
||
if (material.color) material.color.setHex(0x809497);
|
||
if (material.emissive) material.emissive.setHex(0x000000);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
|
||
material.transparent = true;
|
||
material.opacity = 0.3;
|
||
material.depthWrite = false;
|
||
material.depthTest = true;
|
||
material.side = THREE.DoubleSide;
|
||
material.needsUpdate = true;
|
||
return material;
|
||
});
|
||
object.material = Array.isArray(object.material) ? neutralMaterials : neutralMaterials[0];
|
||
if (categories.every((category) => category === "glass")) {
|
||
object.renderOrder = 37;
|
||
if (object.userData.twinOutline?.material) {
|
||
object.userData.twinOutline.material.color.setHex(0x5d747a);
|
||
object.userData.twinOutline.material.opacity = 0.48;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function styleReferencePassiveRearExterior(THREE, vehicle) {
|
||
const rearLampMeshes = [];
|
||
const rearBumperMeshes = [];
|
||
const bounds = new THREE.Box3();
|
||
const sourceMeshes = [];
|
||
const bumperSourceNames = new Set([
|
||
"bodypaint_a",
|
||
"blk_part",
|
||
"black_matte",
|
||
"crom_part",
|
||
"uc001",
|
||
]);
|
||
vehicle.updateMatrixWorld(true);
|
||
vehicle.traverse((object) => {
|
||
if (object.isMesh && object.geometry?.attributes?.position) sourceMeshes.push(object);
|
||
});
|
||
|
||
sourceMeshes.forEach((object) => {
|
||
bounds.setFromObject(object);
|
||
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
|
||
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
|
||
const objectName = String(object.name || "").toLowerCase();
|
||
const isRearLight = (
|
||
bounds.max.z < -2.65
|
||
&& (
|
||
categories.includes("light")
|
||
|| /(?:brake|brk|red_light)/.test(objectName)
|
||
)
|
||
);
|
||
if (isRearLight) {
|
||
const isRedLens = /(?:brake|brk|red_light)/.test(objectName)
|
||
|| sourceMaterials.some((material) => /rear_emiss/i.test(material?.name || ""));
|
||
const styledMaterials = sourceMaterials.map((source) => {
|
||
if (!source) return source;
|
||
const material = source.clone();
|
||
if (material.color) material.color.setHex(isRedLens ? 0x8f666a : 0x657174);
|
||
if (material.emissive) material.emissive.setHex(0x000000);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
|
||
material.transparent = true;
|
||
material.opacity = isRedLens ? 0.4 : 0.2;
|
||
material.depthWrite = false;
|
||
material.depthTest = true;
|
||
material.side = THREE.DoubleSide;
|
||
material.toneMapped = false;
|
||
material.needsUpdate = true;
|
||
return material;
|
||
});
|
||
object.material = Array.isArray(object.material) ? styledMaterials : styledMaterials[0];
|
||
object.renderOrder = 39;
|
||
let outline = object.userData.twinOutline;
|
||
if (!outline) {
|
||
outline = addTwinMeshOutline(THREE, object, {
|
||
color: isRedLens ? 0x745459 : 0x53666a,
|
||
lineOpacity: isRedLens ? 0.44 : 0.4,
|
||
fillOpacity: isRedLens ? 0.4 : 0.2,
|
||
renderOrder: 40,
|
||
}, 34);
|
||
} else if (outline.material) {
|
||
outline.material.color.setHex(isRedLens ? 0x745459 : 0x53666a);
|
||
outline.material.opacity = isRedLens ? 0.44 : 0.4;
|
||
}
|
||
rearLampMeshes.push(object);
|
||
return;
|
||
}
|
||
|
||
if (!bumperSourceNames.has(objectName)) return;
|
||
const originalGeometry = object.geometry;
|
||
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
|
||
const positions = source.getAttribute("position");
|
||
const selectedVertices = [];
|
||
const a = new THREE.Vector3();
|
||
const b = new THREE.Vector3();
|
||
const c = new THREE.Vector3();
|
||
const centroid = new THREE.Vector3();
|
||
for (let index = 0; index < positions.count; index += 3) {
|
||
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
|
||
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
|
||
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
|
||
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
|
||
if (
|
||
centroid.z <= -3.28
|
||
&& centroid.y >= 0.18
|
||
&& centroid.y <= 1.42
|
||
) {
|
||
selectedVertices.push(index, index + 1, index + 2);
|
||
}
|
||
}
|
||
if (!selectedVertices.length) {
|
||
if (source !== originalGeometry) source.dispose();
|
||
return;
|
||
}
|
||
const isDarkTrim = /(?:blk|black|uc)/.test(objectName);
|
||
const isChrome = /crom/.test(objectName);
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: isDarkTrim ? 0x48575a : (isChrome ? 0x99a6a8 : 0x78898d),
|
||
transparent: true,
|
||
opacity: isDarkTrim ? 0.2 : 0.16,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
side: THREE.DoubleSide,
|
||
toneMapped: false,
|
||
});
|
||
const bumper = new THREE.Mesh(
|
||
makeTriangleSubsetGeometry(THREE, source, selectedVertices),
|
||
material,
|
||
);
|
||
bumper.name = `${object.name || "rear"}_passive_bumper`;
|
||
bumper.userData = { twinRole: "reference_passive_rear_bumper" };
|
||
bumper.renderOrder = 38;
|
||
bumper.castShadow = false;
|
||
bumper.receiveShadow = false;
|
||
object.add(bumper);
|
||
addTwinMeshOutline(THREE, bumper, {
|
||
color: isDarkTrim ? 0x3d5054 : 0x52696e,
|
||
lineOpacity: 0.42,
|
||
fillOpacity: isDarkTrim ? 0.2 : 0.16,
|
||
renderOrder: 39,
|
||
}, 34);
|
||
rearBumperMeshes.push(bumper);
|
||
if (source !== originalGeometry) source.dispose();
|
||
});
|
||
|
||
return { rearLampMeshes, rearBumperMeshes };
|
||
}
|
||
|
||
function emphasizeReferenceFrontLampMeshes(THREE, meshes) {
|
||
const materials = new Set();
|
||
meshes.forEach((mesh) => {
|
||
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
|
||
const emphasizedMaterials = sourceMaterials.map((source, index) => {
|
||
if (!source || !selectedIndexes.has(index)) return source;
|
||
const material = source.clone();
|
||
if (material.color) material.color.setHex(0x2e7fd4);
|
||
if (material.emissive) material.emissive.setHex(0x48a4f2);
|
||
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.95;
|
||
material.transparent = true;
|
||
material.opacity = 0.9;
|
||
material.depthWrite = false;
|
||
material.depthTest = true;
|
||
material.toneMapped = false;
|
||
material.needsUpdate = true;
|
||
materials.add(material);
|
||
return material;
|
||
});
|
||
mesh.material = Array.isArray(mesh.material) ? emphasizedMaterials : emphasizedMaterials[0];
|
||
mesh.renderOrder = 41;
|
||
});
|
||
return [...materials];
|
||
}
|
||
|
||
function createReferenceFrontLampHighlights(THREE, meshes) {
|
||
return meshes.flatMap((mesh, index) => {
|
||
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
|
||
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
|
||
const groups = source.groups.length
|
||
? source.groups
|
||
: [{ start: 0, count: source.getAttribute("position").count, materialIndex: 0 }];
|
||
const selectedVertices = [];
|
||
groups.forEach((group) => {
|
||
if (!selectedIndexes.has(group.materialIndex || 0)) return;
|
||
for (let vertexIndex = group.start; vertexIndex < group.start + group.count; vertexIndex += 1) {
|
||
selectedVertices.push(vertexIndex);
|
||
}
|
||
});
|
||
if (!selectedVertices.length) {
|
||
if (source !== mesh.geometry) source.dispose();
|
||
return [];
|
||
}
|
||
const selectedGeometry = makeTriangleSubsetGeometry(THREE, source, selectedVertices);
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: 0x2e7fd4,
|
||
transparent: true,
|
||
opacity: 0.92,
|
||
depthWrite: false,
|
||
depthTest: false,
|
||
side: THREE.DoubleSide,
|
||
toneMapped: false,
|
||
});
|
||
const highlight = mesh.clone(false);
|
||
highlight.name = `${mesh.name || "front_lamp"}_twin_highlight_${index}`;
|
||
highlight.geometry = selectedGeometry;
|
||
highlight.material = material;
|
||
highlight.userData = { twinRole: "reference_front_lamp_highlight" };
|
||
highlight.renderOrder = 46;
|
||
highlight.castShadow = false;
|
||
highlight.receiveShadow = false;
|
||
mesh.parent.add(highlight);
|
||
addTwinMeshOutline(THREE, highlight, {
|
||
color: 0x124a86,
|
||
lineOpacity: 1,
|
||
fillOpacity: 0.92,
|
||
renderOrder: 47,
|
||
}, 8);
|
||
|
||
// The source lamp emitter is a very slim strip. Add a low-opacity copy that
|
||
// expands only across the lamp's own vertical envelope, so the headlamps
|
||
// remain attached to the original lens instead of looking like floating UI.
|
||
const haloGeometry = selectedGeometry.clone();
|
||
const haloPositions = haloGeometry.getAttribute("position");
|
||
if (!haloGeometry.boundingBox) haloGeometry.computeBoundingBox();
|
||
const center = haloGeometry.boundingBox.getCenter(new THREE.Vector3());
|
||
for (let vertexIndex = 0; vertexIndex < haloPositions.count; vertexIndex += 1) {
|
||
const x = haloPositions.getX(vertexIndex);
|
||
const y = haloPositions.getY(vertexIndex);
|
||
const z = haloPositions.getZ(vertexIndex);
|
||
haloPositions.setXYZ(
|
||
vertexIndex,
|
||
center.x + (x - center.x) * 1.035,
|
||
center.y + (y - center.y) * 2.25,
|
||
center.z + (z - center.z) * 1.035,
|
||
);
|
||
}
|
||
haloPositions.needsUpdate = true;
|
||
haloGeometry.computeBoundingBox();
|
||
haloGeometry.computeBoundingSphere();
|
||
const haloMaterial = new THREE.MeshBasicMaterial({
|
||
color: 0x79c7ff,
|
||
transparent: true,
|
||
opacity: 0.38,
|
||
depthWrite: false,
|
||
depthTest: false,
|
||
side: THREE.DoubleSide,
|
||
blending: THREE.AdditiveBlending,
|
||
toneMapped: false,
|
||
});
|
||
const halo = mesh.clone(false);
|
||
halo.name = `${mesh.name || "front_lamp"}_twin_halo_${index}`;
|
||
halo.geometry = haloGeometry;
|
||
halo.material = haloMaterial;
|
||
halo.userData = { twinRole: "reference_front_lamp_halo" };
|
||
halo.renderOrder = 45;
|
||
halo.castShadow = false;
|
||
halo.receiveShadow = false;
|
||
mesh.parent.add(halo);
|
||
if (source !== mesh.geometry) source.dispose();
|
||
return [
|
||
{
|
||
mesh: highlight,
|
||
material,
|
||
idleOpacity: 0.92,
|
||
activeOpacity: 1,
|
||
isHalo: false,
|
||
},
|
||
{
|
||
mesh: halo,
|
||
material: haloMaterial,
|
||
idleOpacity: 0.38,
|
||
activeOpacity: 0.68,
|
||
isHalo: true,
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
function createReferenceFrontLampLensOutlines(THREE, vehicle) {
|
||
const lensOutlines = [];
|
||
vehicle.traverse((object) => {
|
||
if (!object.isMesh || !/^glass001$/i.test(object.name || "")) return;
|
||
const lens = object.clone(false);
|
||
lens.name = `${object.name}_twin_lens_contour`;
|
||
lens.material = new THREE.MeshBasicMaterial({
|
||
color: 0x2d7fd0,
|
||
transparent: true,
|
||
opacity: 0.045,
|
||
depthWrite: false,
|
||
depthTest: true,
|
||
side: THREE.DoubleSide,
|
||
toneMapped: false,
|
||
});
|
||
lens.userData = { twinRole: "reference_front_lamp_lens_contour" };
|
||
lens.renderOrder = 43;
|
||
lens.castShadow = false;
|
||
lens.receiveShadow = false;
|
||
object.parent.add(lens);
|
||
const outline = addTwinMeshOutline(THREE, lens, {
|
||
color: 0x1e6bb8,
|
||
lineOpacity: 0.96,
|
||
fillOpacity: 0.045,
|
||
renderOrder: 44,
|
||
}, 24);
|
||
if (outline) lensOutlines.push({ mesh: lens, outline });
|
||
});
|
||
return lensOutlines;
|
||
}
|
||
|
||
function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
|
||
return new Promise((resolve, reject) => {
|
||
const FBXLoaderClass = window.FBXLoader || THREE.FBXLoader;
|
||
if (!FBXLoaderClass) {
|
||
reject(new Error("FBXLoader is unavailable"));
|
||
return;
|
||
}
|
||
let settled = false;
|
||
const settle = (callback, value) => {
|
||
if (settled) return false;
|
||
settled = true;
|
||
window.clearTimeout(timeoutId);
|
||
callback(value);
|
||
return true;
|
||
};
|
||
const timeoutId = window.setTimeout(() => {
|
||
settle(
|
||
reject,
|
||
new Error(`Reference vehicle load timed out after ${REFERENCE_VEHICLE_LOAD_TIMEOUT_MS} ms`),
|
||
);
|
||
}, REFERENCE_VEHICLE_LOAD_TIMEOUT_MS);
|
||
const manager = new THREE.LoadingManager();
|
||
manager.setURLModifier((url) => {
|
||
const cleanUrl = String(url).replaceAll("\\", "/").split(/[?#]/, 1)[0];
|
||
const fileName = decodeURIComponent(cleanUrl.split("/").pop() || "");
|
||
if (!/\.(png|jpe?g)$/i.test(fileName)) return url;
|
||
return referenceVehicleAssetUrl(fileName);
|
||
});
|
||
const loader = new FBXLoaderClass(manager);
|
||
loader.setResourcePath(REFERENCE_VEHICLE_ROOT);
|
||
loader.load(
|
||
REFERENCE_VEHICLE_URL,
|
||
(vehicle) => {
|
||
if (settled) return;
|
||
try {
|
||
const normalized = normalizeReferenceVehicle(THREE, vehicle);
|
||
const driverCushion = measureReferenceDriverCushion(THREE, vehicle);
|
||
const passengerCushion = measureReferencePassengerCushion(THREE, vehicle);
|
||
const removedFrontSeatTriangles = carveReferenceFrontSeats(THREE, vehicle);
|
||
const referenceWindowPanes = extractReferenceWindowPanes(THREE, vehicle);
|
||
const report = configureReferenceVehicleModel(THREE, vehicle);
|
||
styleReferencePassiveGlass(THREE, vehicle);
|
||
const passiveRearExterior = styleReferencePassiveRearExterior(THREE, vehicle);
|
||
const referenceFrontLampMeshes = collectReferenceFrontLampMeshes(vehicle);
|
||
emphasizeReferenceWindowPanes(THREE, referenceWindowPanes);
|
||
const referenceFrontLampMaterials = emphasizeReferenceFrontLampMeshes(
|
||
THREE,
|
||
referenceFrontLampMeshes,
|
||
);
|
||
const referenceFrontLampHighlights = createReferenceFrontLampHighlights(
|
||
THREE,
|
||
referenceFrontLampMeshes,
|
||
);
|
||
const referenceFrontLampLensOutlines = createReferenceFrontLampLensOutlines(
|
||
THREE,
|
||
vehicle,
|
||
);
|
||
vehicleSystems.referenceWindowPanes = referenceWindowPanes;
|
||
vehicleSystems.referenceFrontLampMaterials = referenceFrontLampMaterials;
|
||
vehicleSystems.referenceFrontLampMeshes = referenceFrontLampMeshes;
|
||
vehicleSystems.referenceFrontLampHighlights = referenceFrontLampHighlights;
|
||
vehicleSystems.referenceFrontLampLensOutlines = referenceFrontLampLensOutlines;
|
||
applyTwinVehicleTheme(vehicleSystems);
|
||
vehicle.name = "REFERENCE_VEHICLE_OUTLINE";
|
||
vehicle.userData.twinRole = "reference_vehicle";
|
||
vehicle.traverse((object) => object.layers.set(1));
|
||
scene.add(vehicle);
|
||
vehicleSystems.fallbackExteriorObjects.forEach((object) => {
|
||
object.visible = false;
|
||
});
|
||
vehicleSystems.fallbackInteriorObjects.forEach((object) => {
|
||
object.visible = false;
|
||
});
|
||
console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({
|
||
meshes: report.meshes,
|
||
size: normalized.size.toArray().map((value) => Number(value.toFixed(3))),
|
||
removedFrontSeatTriangles,
|
||
windowPanes: Object.fromEntries(
|
||
Object.entries(referenceWindowPanes).map(([key, value]) => [key, value.length]),
|
||
),
|
||
frontLampMaterials: referenceFrontLampMaterials.length,
|
||
frontLampMeshes: referenceFrontLampMeshes.length,
|
||
frontLampHighlights: referenceFrontLampHighlights.length,
|
||
frontLampLensOutlines: referenceFrontLampLensOutlines.length,
|
||
rearLampMeshes: passiveRearExterior.rearLampMeshes.length,
|
||
rearBumperMeshes: passiveRearExterior.rearBumperMeshes.length,
|
||
driverCushion,
|
||
passengerCushion,
|
||
}));
|
||
settle(resolve, { vehicle, report, normalized, driverCushion, passengerCushion });
|
||
} catch (error) {
|
||
settle(reject, error);
|
||
}
|
||
},
|
||
(event) => {
|
||
if (!fallback || !event.total) return;
|
||
const progress = Math.min(99, Math.round(event.loaded / event.total * 100));
|
||
const label = fallback.querySelector("span");
|
||
if (label) label.textContent = `正在加载整车参考模型 · ${progress}%`;
|
||
},
|
||
(error) => settle(reject, error),
|
||
);
|
||
});
|
||
}
|
||
|
||
function initSeatTwin() {
|
||
const canvas = $("#seatTwinCanvas");
|
||
const stage = $("#seatTwin");
|
||
const fallback = $("#twinFallback");
|
||
if (!canvas || !stage || state.seatTwin) return;
|
||
const stageHasLayout = () => {
|
||
const bounds = stage.getBoundingClientRect();
|
||
return stage.isConnected && bounds.width >= 2 && bounds.height >= 2;
|
||
};
|
||
if (!stageHasLayout()) {
|
||
if (stage.dataset.twinInitPending === "true") return;
|
||
stage.dataset.twinInitPending = "true";
|
||
let pendingObserver = null;
|
||
const retryInitialization = () => {
|
||
if (!stageHasLayout()) return;
|
||
pendingObserver?.disconnect();
|
||
window.removeEventListener("resize", scheduleRetry);
|
||
window.removeEventListener("cognitive-agent:module-change", scheduleRetry);
|
||
delete stage.dataset.twinInitPending;
|
||
initSeatTwin();
|
||
};
|
||
const scheduleRetry = () => window.requestAnimationFrame(retryInitialization);
|
||
if (window.ResizeObserver) {
|
||
pendingObserver = new ResizeObserver(retryInitialization);
|
||
pendingObserver.observe(stage);
|
||
}
|
||
window.addEventListener("resize", scheduleRetry);
|
||
window.addEventListener("cognitive-agent:module-change", scheduleRetry);
|
||
scheduleRetry();
|
||
return;
|
||
}
|
||
if (!window.THREE) {
|
||
fallback.classList.add("error");
|
||
fallback.innerHTML = '<i data-lucide="triangle-alert"></i><span>3D 引擎加载失败,精确状态仍可用</span>';
|
||
refreshIcons();
|
||
return;
|
||
}
|
||
|
||
const THREE = window.THREE;
|
||
let renderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
|
||
} catch (error) {
|
||
console.error("[vehicle-twin] failed to initialize WebGL", error);
|
||
fallback.classList.add("error");
|
||
fallback.innerHTML = '<i data-lucide="triangle-alert"></i><span>3D 渲染器初始化失败,状态数据仍可用</span>';
|
||
refreshIcons();
|
||
return;
|
||
}
|
||
renderer.autoClear = false;
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
else renderer.outputEncoding = THREE.sRGBEncoding;
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
|
||
const scene = new THREE.Scene();
|
||
const twinPalette = currentTwinThemePalette();
|
||
scene.background = new THREE.Color(twinPalette.sceneBackground);
|
||
scene.fog = new THREE.Fog(twinPalette.sceneBackground, 12, 19);
|
||
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
|
||
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2);
|
||
hemisphereLight.layers.enable(1);
|
||
scene.add(hemisphereLight);
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
|
||
keyLight.layers.enable(1);
|
||
keyLight.position.set(-4, 7, 6);
|
||
keyLight.castShadow = true;
|
||
keyLight.shadow.mapSize.set(1024, 1024);
|
||
keyLight.shadow.camera.left = -6;
|
||
keyLight.shadow.camera.right = 6;
|
||
keyLight.shadow.camera.top = 6;
|
||
keyLight.shadow.camera.bottom = -4;
|
||
scene.add(keyLight);
|
||
const fillLight = new THREE.DirectionalLight(twinPalette.fillLight, 1.15);
|
||
fillLight.layers.enable(1);
|
||
fillLight.position.set(5, 3, -5);
|
||
scene.add(fillLight);
|
||
|
||
const floor = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(30, 30),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0xffffff,
|
||
transparent: true,
|
||
opacity: 1,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide,
|
||
toneMapped: false,
|
||
}),
|
||
);
|
||
floor.rotation.x = -Math.PI / 2;
|
||
floor.position.y = -0.16;
|
||
floor.renderOrder = 1;
|
||
scene.add(floor);
|
||
const vehicleSystems = createTwinCabin(THREE, scene);
|
||
applyTwinOutlineStyle(THREE, vehicleSystems.root, {
|
||
color: 0x5a7898,
|
||
lineOpacity: 0.46,
|
||
fillOpacity: 0.012,
|
||
renderOrder: 40,
|
||
thresholdAngle: 30,
|
||
});
|
||
configureTwinSolidEntity(THREE, vehicleSystems.ventMeshes, {
|
||
renderOrder: 52,
|
||
});
|
||
configureTwinSolidEntity(THREE, vehicleSystems.ambientMeshes, {
|
||
renderOrder: 56,
|
||
});
|
||
configureTwinSolidEntity(THREE, vehicleSystems.roofGlass, {
|
||
transparent: true,
|
||
opacity: 0.52,
|
||
depthWrite: false,
|
||
renderOrder: 58,
|
||
});
|
||
vehicleSystems.roofDepthMask = addTwinGlassDepthMask(THREE, vehicleSystems.roofGlass);
|
||
configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes.slice(1), {
|
||
transparent: true,
|
||
opacity: 0.88,
|
||
depthWrite: false,
|
||
renderOrder: 59,
|
||
});
|
||
configureTwinSolidEntity(THREE, vehicleSystems.roofShade, {
|
||
transparent: true,
|
||
opacity: 0.08,
|
||
renderOrder: 49,
|
||
});
|
||
configureTwinSolidEntity(THREE, vehicleSystems.headlampMeshes, {
|
||
transparent: false,
|
||
opacity: 1,
|
||
depthWrite: true,
|
||
renderOrder: 57,
|
||
});
|
||
|
||
const models = {
|
||
passenger: createTwinSeat(THREE, -1.05, 0x4a78af),
|
||
driver: createTwinSeat(THREE, 1.05, 0x08766e),
|
||
};
|
||
Object.entries(models).forEach(([seat, model]) => {
|
||
configureActivePassengerSeatStyle(model);
|
||
model.root.scale.setScalar(0.5);
|
||
model.root.position.set(seat === "driver" ? 0.62 : -0.62, 0.38, 0.25);
|
||
model.root.userData.baseRootY = model.root.position.y;
|
||
model.root.userData.twinRole = `active_front_${seat}`;
|
||
configureTwinSolidEntity(THREE, model.root, { renderOrder: 51 });
|
||
configureActiveSeatDepthLayers(THREE, model);
|
||
scene.add(model.root);
|
||
});
|
||
|
||
const fixedModels = {
|
||
rear: createTwinRearBench(THREE),
|
||
};
|
||
applyTwinOutlineStyle(THREE, fixedModels.rear, {
|
||
color: 0x627b97,
|
||
lineOpacity: 0.42,
|
||
fillOpacity: 0.01,
|
||
renderOrder: 41,
|
||
thresholdAngle: 26,
|
||
});
|
||
vehicleSystems.root.add(fixedModels.rear);
|
||
[
|
||
vehicleSystems.root,
|
||
models.passenger.root,
|
||
models.driver.root,
|
||
fixedModels.rear,
|
||
].forEach((root) => {
|
||
root.traverse((object) => object.layers.set(1));
|
||
});
|
||
|
||
const initialDevice = () => ({
|
||
ventilation: { state: "off", level: 0 },
|
||
heating: { state: "off", level: 0 },
|
||
position: Object.fromEntries(Object.entries(TWIN_DEFAULT_POSITIONS).map(([axis, position_pct]) => [axis, { position_pct, fault: false }])),
|
||
});
|
||
state.seatTwin = {
|
||
renderer,
|
||
scene,
|
||
camera,
|
||
models,
|
||
fixedModels,
|
||
vehicleSystems,
|
||
floor,
|
||
fillLight,
|
||
selected: "passenger",
|
||
actuatorState: { passenger: initialDevice(), driver: initialDevice() },
|
||
explicitAngleTargets: { passenger: {}, driver: {} },
|
||
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
|
||
dragging: false,
|
||
pointer: { x: 0, y: 0 },
|
||
};
|
||
applyTwinSceneTheme(state.seatTwin);
|
||
window.addEventListener("cognitive-agent:module-change", () => {
|
||
applyTwinSceneTheme(state.seatTwin);
|
||
});
|
||
window.addEventListener("cognitive-agent:theme-change", () => {
|
||
applyTwinSceneTheme(state.seatTwin);
|
||
});
|
||
|
||
canvas.dataset.referenceModelState = "loading";
|
||
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
|
||
.then(({ vehicle, report, normalized, driverCushion, passengerCushion }) => {
|
||
if (!state.seatTwin) return;
|
||
state.seatTwin.referenceVehicle = vehicle;
|
||
state.seatTwin.referenceReport = report;
|
||
state.seatTwin.referenceBounds = normalized.bounds;
|
||
applyTwinSceneTheme(state.seatTwin);
|
||
fixedModels.rear.visible = false;
|
||
[["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => {
|
||
if (!referenceCushion) return;
|
||
const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
|
||
const verticalCorrection = THREE.MathUtils.clamp(
|
||
referenceCushion.surfaceY - cushionBoundsBefore.max.y,
|
||
-0.08,
|
||
0.42,
|
||
);
|
||
models[seat].root.position.y += verticalCorrection;
|
||
models[seat].root.userData.baseRootY = models[seat].root.position.y;
|
||
models[seat].root.updateMatrixWorld(true);
|
||
const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
|
||
models[seat].root.userData.cushionSurfaceY = cushionBoundsAfter.max.y;
|
||
canvas.dataset[`${seat}SeatCushionAligned`] = String(
|
||
Math.abs(cushionBoundsAfter.max.y - referenceCushion.surfaceY) < 0.025
|
||
);
|
||
});
|
||
models.passenger.root.updateMatrixWorld(true);
|
||
const activeSeatBounds = new THREE.Box3().setFromObject(models.passenger.root);
|
||
const activeSeatSize = activeSeatBounds.getSize(new THREE.Vector3());
|
||
const activeSeatContained = (
|
||
normalized.bounds.containsPoint(activeSeatBounds.min)
|
||
&& normalized.bounds.containsPoint(activeSeatBounds.max)
|
||
);
|
||
canvas.dataset.activeSeatContained = String(activeSeatContained);
|
||
canvas.dataset.activeSeatSize = activeSeatSize
|
||
.toArray()
|
||
.map((value) => value.toFixed(2))
|
||
.join("x");
|
||
canvas.dataset.referenceModelState = "loaded";
|
||
fallback.hidden = true;
|
||
})
|
||
.catch((error) => {
|
||
console.error("[vehicle-twin] failed to load reference vehicle model", error);
|
||
canvas.dataset.referenceModelState = "failed";
|
||
fallback.classList.add("error");
|
||
fallback.innerHTML = '<i data-lucide="triangle-alert"></i><span>实车模型加载失败,已切换轻量回退模型</span>';
|
||
refreshIcons();
|
||
window.setTimeout(() => { fallback.hidden = true; }, 3200);
|
||
});
|
||
|
||
function resetView() {
|
||
Object.assign(state.seatTwin.view, {
|
||
yaw: -0.62,
|
||
pitch: 0.31,
|
||
distance: 10.2,
|
||
targetX: 0,
|
||
});
|
||
}
|
||
|
||
let stageWasVisible = false;
|
||
function resize() {
|
||
const width = Math.max(1, Math.round(stage.clientWidth));
|
||
const height = Math.max(1, Math.round(stage.clientHeight));
|
||
renderer.setSize(width, height, false);
|
||
camera.aspect = width / height;
|
||
camera.updateProjectionMatrix();
|
||
state.seatTwin.narrowViewport = width < 600;
|
||
}
|
||
|
||
function synchronizeTwinViewport({ reset = false } = {}) {
|
||
if (!stageHasLayout()) {
|
||
stageWasVisible = false;
|
||
return false;
|
||
}
|
||
resize();
|
||
if (reset || !stageWasVisible) resetView();
|
||
stageWasVisible = true;
|
||
return true;
|
||
}
|
||
|
||
const scheduleViewportSync = () => {
|
||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
||
synchronizeTwinViewport();
|
||
}));
|
||
};
|
||
if (window.ResizeObserver) {
|
||
const stageObserver = new ResizeObserver(() => synchronizeTwinViewport());
|
||
stageObserver.observe(stage);
|
||
state.seatTwin.resizeObserver = stageObserver;
|
||
}
|
||
window.addEventListener("resize", scheduleViewportSync);
|
||
window.addEventListener("cognitive-agent:module-change", scheduleViewportSync);
|
||
canvas.addEventListener("webglcontextlost", (event) => {
|
||
event.preventDefault();
|
||
canvas.dataset.contextLost = "true";
|
||
fallback.hidden = false;
|
||
fallback.classList.add("error");
|
||
fallback.innerHTML = '<i data-lucide="triangle-alert"></i><span>3D 上下文已中断,正在等待恢复</span>';
|
||
refreshIcons();
|
||
});
|
||
canvas.addEventListener("webglcontextrestored", () => {
|
||
delete canvas.dataset.contextLost;
|
||
synchronizeTwinViewport({ reset: true });
|
||
const modelState = canvas.dataset.referenceModelState;
|
||
fallback.hidden = modelState === "loaded";
|
||
if (modelState === "loading") {
|
||
fallback.classList.remove("error");
|
||
fallback.innerHTML = '<i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span>';
|
||
refreshIcons();
|
||
} else if (modelState === "failed") {
|
||
fallback.classList.add("error");
|
||
fallback.innerHTML = '<i data-lucide="triangle-alert"></i><span>实车模型加载失败,已切换轻量回退模型</span>';
|
||
refreshIcons();
|
||
window.setTimeout(() => { fallback.hidden = true; }, 3200);
|
||
}
|
||
});
|
||
synchronizeTwinViewport({ reset: true });
|
||
stage.addEventListener("pointerdown", (event) => {
|
||
state.seatTwin.dragging = true;
|
||
state.seatTwin.pointer = { x: event.clientX, y: event.clientY };
|
||
stage.setPointerCapture(event.pointerId);
|
||
stage.classList.add("dragging");
|
||
});
|
||
stage.addEventListener("pointermove", (event) => {
|
||
const twin = state.seatTwin;
|
||
if (!twin?.dragging) return;
|
||
const dx = event.clientX - twin.pointer.x;
|
||
const dy = event.clientY - twin.pointer.y;
|
||
twin.pointer = { x: event.clientX, y: event.clientY };
|
||
twin.view.yaw -= dx * 0.006;
|
||
twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004));
|
||
});
|
||
const endDrag = () => {
|
||
if (!state.seatTwin) return;
|
||
state.seatTwin.dragging = false;
|
||
stage.classList.remove("dragging");
|
||
};
|
||
stage.addEventListener("pointerup", endDrag);
|
||
stage.addEventListener("pointercancel", endDrag);
|
||
stage.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
const twin = state.seatTwin;
|
||
twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006));
|
||
}, { passive: false });
|
||
$("#twinReset").addEventListener("click", resetView);
|
||
$$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat)));
|
||
|
||
const degrees = THREE.MathUtils.degToRad;
|
||
const hotspotWorldAnchor = new THREE.Vector3();
|
||
const projectedHotspot = new THREE.Vector3();
|
||
const clampLabelCoordinate = (value, minimum, maximum) => (
|
||
maximum < minimum ? (minimum + maximum) / 2 : Math.max(minimum, Math.min(maximum, value))
|
||
);
|
||
|
||
function positionTwinDeviceHotspots() {
|
||
const twin = state.seatTwin;
|
||
const hotspotLayer = $("#v2TwinHotspots");
|
||
if (!twin || !hotspotLayer) return;
|
||
const width = canvas.clientWidth;
|
||
const height = canvas.clientHeight;
|
||
const labelMargin = width < 520 ? 8 : 12;
|
||
const offsetScale = THREE.MathUtils.clamp(width / 620, 0.72, 1);
|
||
scene.updateMatrixWorld(true);
|
||
camera.updateMatrixWorld();
|
||
Object.entries(TWIN_DEVICE_HOTSPOT_LAYOUT).forEach(([deviceId, layout]) => {
|
||
const button = $(`[data-twin-device="${deviceId}"]`, hotspotLayer);
|
||
if (!button || !twinDeviceWorldAnchor(THREE, twin, deviceId, hotspotWorldAnchor)) return;
|
||
projectedHotspot.copy(hotspotWorldAnchor).project(camera);
|
||
const anchorX = (projectedHotspot.x * 0.5 + 0.5) * width;
|
||
const anchorY = (-projectedHotspot.y * 0.5 + 0.5) * height;
|
||
const visible = (
|
||
projectedHotspot.z > -1
|
||
&& projectedHotspot.z < 1
|
||
&& anchorX >= 0
|
||
&& anchorX <= width
|
||
&& anchorY >= 0
|
||
&& anchorY <= height
|
||
);
|
||
button.hidden = !visible;
|
||
if (!visible) return;
|
||
button.style.left = `${anchorX}px`;
|
||
button.style.top = `${anchorY}px`;
|
||
button.dataset.anchorSource = "projected-3d";
|
||
|
||
const label = $(".v2-twin-hotspot-label", button);
|
||
const halfWidth = Math.max(44, (label?.offsetWidth || 94) / 2);
|
||
const halfHeight = Math.max(20, (label?.offsetHeight || 42) / 2);
|
||
const preferredX = anchorX + layout.labelOffset[0] * offsetScale;
|
||
const preferredY = anchorY + layout.labelOffset[1] * offsetScale;
|
||
const labelX = clampLabelCoordinate(
|
||
preferredX,
|
||
labelMargin + halfWidth,
|
||
width - labelMargin - halfWidth,
|
||
);
|
||
const labelY = clampLabelCoordinate(
|
||
preferredY,
|
||
labelMargin + halfHeight,
|
||
height - labelMargin - halfHeight,
|
||
);
|
||
const dx = labelX - anchorX;
|
||
const dy = labelY - anchorY;
|
||
const distance = Math.max(1, Math.hypot(dx, dy));
|
||
const unitX = dx / distance;
|
||
const unitY = dy / distance;
|
||
const horizontalBoundary = Math.abs(unitX) > 0.001
|
||
? halfWidth / Math.abs(unitX)
|
||
: Number.POSITIVE_INFINITY;
|
||
const verticalBoundary = Math.abs(unitY) > 0.001
|
||
? halfHeight / Math.abs(unitY)
|
||
: Number.POSITIVE_INFINITY;
|
||
const labelBoundary = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary));
|
||
button.style.setProperty("--v2-hotspot-label-x", `${dx}px`);
|
||
button.style.setProperty("--v2-hotspot-label-y", `${dy}px`);
|
||
button.style.setProperty("--v2-hotspot-line-length", `${Math.max(0, distance - labelBoundary + 1)}px`);
|
||
button.style.setProperty("--v2-hotspot-line-angle", `${Math.atan2(dy, dx)}rad`);
|
||
});
|
||
hotspotLayer.dataset.anchorLayout = "projected-3d";
|
||
canvas.dataset.hotspotLayout = "projected-3d";
|
||
window.CognitiveAgentConsoleV2?.positionTwinDevicePopover?.();
|
||
}
|
||
|
||
function animate(timestamp = 0) {
|
||
const twin = state.seatTwin;
|
||
if (!twin) return;
|
||
if (!stageHasLayout() || canvas.dataset.contextLost === "true") {
|
||
stageWasVisible = false;
|
||
window.requestAnimationFrame(animate);
|
||
return;
|
||
}
|
||
if (!stageWasVisible) synchronizeTwinViewport({ reset: true });
|
||
Object.values(twin.models).forEach((model) => {
|
||
Object.keys(model.positions).forEach((axis) => {
|
||
const delta = model.targets[axis] - model.positions[axis];
|
||
model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
||
});
|
||
Object.keys(model.angles).forEach((axis) => {
|
||
const delta = model.angleTargets[axis] - model.angles[axis];
|
||
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
|
||
});
|
||
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
|
||
model.root.position.y = Number(model.root.userData.baseRootY ?? 0.38)
|
||
+ (model.positions.height - 50) * 0.003;
|
||
const cushionAngle = model.positions.cushion_tilt * 0.09;
|
||
model.cushionPivot.rotation.x = degrees(-cushionAngle);
|
||
model.backrestPivot.rotation.x = (
|
||
model.cushionPivot.rotation.x
|
||
- degrees(model.angles.backrest_recline - 90)
|
||
);
|
||
model.legrestPivot.rotation.x = degrees(
|
||
180 - cushionAngle - model.angles.legrest_height,
|
||
);
|
||
model.legrestExtension.position.z = model.positions.legrest_extension * 0.0022;
|
||
updateTwinAngleGuide(
|
||
model.angleGuides.backrest_recline,
|
||
model.outerSide * 0.83,
|
||
0.62,
|
||
-0.5,
|
||
cushionAngle,
|
||
model.angles.backrest_recline,
|
||
0.58,
|
||
);
|
||
updateTwinAngleGuide(
|
||
model.angleGuides.legrest_height,
|
||
-model.outerSide * 0.7,
|
||
0.64,
|
||
0.7,
|
||
cushionAngle + 180,
|
||
model.angles.legrest_height,
|
||
0.48,
|
||
);
|
||
updateTwinThermalVisual(model, timestamp);
|
||
});
|
||
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
|
||
const { view } = twin;
|
||
// Fit the whole reference vehicle against the limiting horizontal field
|
||
// of view. The desktop twin is often almost portrait-shaped, where a
|
||
// fixed narrow-viewport multiplier still clips the front bumper.
|
||
const viewportFit = THREE.MathUtils.clamp(
|
||
1.28 / Math.max(0.64, camera.aspect),
|
||
1,
|
||
1.9,
|
||
);
|
||
const cameraDistance = view.distance * viewportFit;
|
||
const horizontal = Math.cos(view.pitch) * cameraDistance;
|
||
const cameraTargetX = view.targetX;
|
||
camera.position.set(
|
||
cameraTargetX + Math.sin(view.yaw) * horizontal,
|
||
0.82 + Math.sin(view.pitch) * cameraDistance,
|
||
Math.cos(view.yaw) * horizontal,
|
||
);
|
||
camera.lookAt(cameraTargetX, 1.14, -0.08);
|
||
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
|
||
positionTwinDeviceHotspots();
|
||
canvas.dataset.framing = "whole-vehicle-centered";
|
||
renderer.clear();
|
||
camera.layers.set(0);
|
||
renderer.render(scene, camera);
|
||
renderer.clearDepth();
|
||
const sceneBackground = scene.background;
|
||
scene.background = null;
|
||
camera.layers.set(1);
|
||
renderer.render(scene, camera);
|
||
scene.background = sceneBackground;
|
||
camera.layers.set(0);
|
||
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
|
||
window.requestAnimationFrame(animate);
|
||
}
|
||
setTwinSelection("passenger");
|
||
animate();
|
||
}
|
||
|
||
function showToast(message) {
|
||
const toast = $("#toast");
|
||
toast.textContent = message;
|
||
toast.classList.add("show");
|
||
window.clearTimeout(showToast.timer);
|
||
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
|
||
}
|
||
|
||
function isLocalDecisionHost() {
|
||
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
|
||
}
|
||
|
||
function vehicleTransportOptions() {
|
||
return Array.isArray(state.runtime?.vehicle_transports?.options)
|
||
? state.runtime.vehicle_transports.options
|
||
: [];
|
||
}
|
||
|
||
function selectedVehicleTransport() {
|
||
return vehicleTransportOptions().find((item) => item.id === state.controlTransport)
|
||
|| (state.controlTransport === "wireless_mqtt"
|
||
? state.runtime?.vehicle_mqtt
|
||
: state.runtime?.vehicle_can)
|
||
|| null;
|
||
}
|
||
|
||
function wiredCanConnectionOnline(runtime = state.runtime?.vehicle_can) {
|
||
return runtime?.state_sync?.state === "online"
|
||
|| state.wiredCanProbe.passenger === true
|
||
|| state.wiredCanProbe.vehicle === true;
|
||
}
|
||
|
||
function transportConnectionOnline(id, runtime) {
|
||
return id === "wired_can"
|
||
? wiredCanConnectionOnline(runtime)
|
||
: runtime?.state_sync?.state === "online";
|
||
}
|
||
|
||
function setFeedbackSignal(scope, connected, requestedTransport = state.controlTransport) {
|
||
if (requestedTransport !== state.controlTransport) return;
|
||
const next = connected === true;
|
||
const datasetKey = `${scope}FeedbackState`;
|
||
const datasetValue = next ? "online" : "offline";
|
||
if (state.feedbackSignals[scope] === next
|
||
&& document.body?.dataset[datasetKey] === datasetValue) return;
|
||
state.feedbackSignals[scope] = next;
|
||
if (document.body) {
|
||
document.body.dataset[datasetKey] = datasetValue;
|
||
}
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-feedback-state", {
|
||
detail: { scope, connected: next, control_transport: requestedTransport },
|
||
}));
|
||
}
|
||
|
||
function updateWiredCanProbe(scope, connected, requestedTransport) {
|
||
if (requestedTransport !== "wired_can" || requestedTransport !== state.controlTransport) return;
|
||
const next = connected === true;
|
||
if (state.wiredCanProbe[scope] === next) return;
|
||
state.wiredCanProbe[scope] = next;
|
||
renderControlTransport();
|
||
}
|
||
|
||
function resetTransportConnectionEvidence() {
|
||
state.wiredCanProbe.passenger = false;
|
||
state.wiredCanProbe.vehicle = false;
|
||
["passenger", "vehicle"].forEach((scope) => {
|
||
state.feedbackSignals[scope] = false;
|
||
if (document.body) document.body.dataset[`${scope}FeedbackState`] = "offline";
|
||
});
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-feedback-state", {
|
||
detail: { scope: "all", connected: false, control_transport: state.controlTransport },
|
||
}));
|
||
}
|
||
|
||
function isHardwareExecutionReady() {
|
||
return Boolean(
|
||
state.runtime?.hardware?.configured || selectedVehicleTransport()?.configured,
|
||
);
|
||
}
|
||
|
||
function hardwareExecutionIssue(
|
||
hardware = state.runtime?.hardware || {},
|
||
transport = selectedVehicleTransport() || {},
|
||
) {
|
||
const stateError = String(transport?.state_sync?.last_error || "").trim();
|
||
if (/VCI_OpenDevice failed/i.test(stateError)) {
|
||
return "ZLG 设备打开失败:请检查 USB 读写权限,或关闭占用适配器的其他程序";
|
||
}
|
||
if (stateError) return stateError;
|
||
if (transport.state_configured === true && transport.configured !== true) {
|
||
return "CAN 状态监听已配置,但真实控制授权尚未配置";
|
||
}
|
||
if (state.controlTransport === "wireless_mqtt") {
|
||
return "无线 MQTT 控制后端尚未配置";
|
||
}
|
||
if (hardware.enabled === true && hardware.adapter_configured !== true) {
|
||
return "副驾 CH0 CAN 适配器尚未配置";
|
||
}
|
||
return "ZLG 有线 CAN 后端尚未通过执行配置校验";
|
||
}
|
||
|
||
function isDriverOnlyCh1Runtime() {
|
||
const transport = selectedVehicleTransport();
|
||
return transport?.configured === true
|
||
&& transport?.target_policy === "ch1_body_can_driver_only"
|
||
&& state.runtime?.hardware?.configured !== true;
|
||
}
|
||
|
||
function renderControlTransport() {
|
||
const options = vehicleTransportOptions();
|
||
$$('[data-control-transport]').forEach((button) => {
|
||
const id = button.dataset.controlTransport;
|
||
const runtime = options.find((item) => item.id === id)
|
||
|| (id === "wireless_mqtt" ? state.runtime?.vehicle_mqtt : state.runtime?.vehicle_can);
|
||
const selected = id === state.controlTransport;
|
||
button.classList.toggle("active", selected);
|
||
button.setAttribute("aria-pressed", String(selected));
|
||
// Transport choice is intentionally manual. An unavailable connection is
|
||
// shown as offline, but it must never disable or silently change the
|
||
// operator's wired/wireless selection.
|
||
button.disabled = false;
|
||
const connectionState = transportConnectionOnline(id, runtime)
|
||
? "online"
|
||
: "offline";
|
||
button.dataset.connectionState = connectionState;
|
||
button.setAttribute(
|
||
"aria-label",
|
||
`${id === "wireless_mqtt" ? "无线 CAN" : "有线 CAN"},信号链路${connectionState === "online" ? "正常" : "断开"}`,
|
||
);
|
||
button.title = runtime?.state_configured === true
|
||
? runtime?.state_sync?.last_error || ""
|
||
: `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`;
|
||
});
|
||
renderCabinLiveStatus();
|
||
}
|
||
|
||
function selectControlTransport(transport) {
|
||
if (!["wired_can", "wireless_mqtt"].includes(transport)) return;
|
||
const changed = state.controlTransport !== transport;
|
||
state.controlTransport = transport;
|
||
localStorage.setItem("cognitive-agent.control-transport", transport);
|
||
if (changed) {
|
||
state.conversationId = createConversationId();
|
||
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
|
||
state.seatFeedbackSources.passenger = "unavailable";
|
||
state.seatFeedbackSources.driver = "unavailable";
|
||
resetTransportConnectionEvidence();
|
||
resetVehicleStatusDisplay();
|
||
}
|
||
state.vehicleStateSnapshot = null;
|
||
state.vehicleStateTurn = -1;
|
||
renderControlTransport();
|
||
renderCabinLiveStatus();
|
||
applyRuntimeTargetPolicy(state.runtime);
|
||
}
|
||
|
||
function renderHardwareAccess(status) {
|
||
state.hardwareAccess = status;
|
||
}
|
||
|
||
async function loadHardwareAccessStatus({ silent = false } = {}) {
|
||
try {
|
||
const query = new URLSearchParams({ client_id: state.hardwareClientId });
|
||
const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
|
||
if (response.status === 401) return;
|
||
const payload = await response.json();
|
||
if (!response.ok) throw new Error(payload.message || payload.error);
|
||
renderHardwareAccess(payload);
|
||
} catch (error) {
|
||
if (!silent) showToast(`使用权状态读取失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
function selectProvider(provider) {
|
||
state.provider = provider;
|
||
$$(".segment[data-provider]").forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.provider === provider);
|
||
});
|
||
}
|
||
|
||
function updateSeatFeedbackModeLabel() {
|
||
const label = $("#seatFeedbackModeLabel");
|
||
const descriptions = {
|
||
physical_can: "实车已同步",
|
||
physical_partial: "物理反馈不完整",
|
||
physical_can_ch1: "实车已同步",
|
||
wireless_mqtt_ch0: "MQTT 已同步",
|
||
wireless_mqtt_ch1: "MQTT 已同步",
|
||
simulated_hardware: "模拟 HIL",
|
||
simulated: "虚拟反馈",
|
||
unavailable: "不可用",
|
||
};
|
||
const compactDescriptions = {
|
||
physical_can: "实车",
|
||
physical_partial: "部分",
|
||
physical_can_ch1: "实车",
|
||
wireless_mqtt_ch0: "MQTT",
|
||
wireless_mqtt_ch1: "MQTT",
|
||
simulated_hardware: "模拟",
|
||
simulated: "虚拟",
|
||
unavailable: "未连接",
|
||
};
|
||
const channels = { passenger: "CH0", driver: "CH1" };
|
||
Object.entries(channels).forEach(([seat, channel]) => {
|
||
const source = state.seatFeedbackSources[seat] || "unavailable";
|
||
const badge = $(`[data-seat-feedback="${seat}"]`);
|
||
if (!badge) return;
|
||
badge.dataset.source = source;
|
||
badge.textContent = `${channel} · ${compactDescriptions[source] || source}`;
|
||
badge.title = `${channel} · ${descriptions[source] || source}`;
|
||
});
|
||
if (label) label.textContent = `CH0 副驾 · ${descriptions[state.seatFeedbackSources.passenger]} / CH1 主驾 · ${descriptions[state.seatFeedbackSources.driver]}`;
|
||
}
|
||
|
||
function selectExecutionMode(mode, { remember = true } = {}) {
|
||
const hardwareExecutionReady = isHardwareExecutionReady();
|
||
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return false;
|
||
if (remember) state.preferredExecutionMode = mode;
|
||
state.executionMode = mode;
|
||
$$('[data-execution-mode]').forEach((button) => {
|
||
button.classList.toggle("active", button.dataset.executionMode === mode);
|
||
});
|
||
const hardware = mode === "hardware";
|
||
const simulatedHardware = mode === "simulated_hardware";
|
||
const mqttSelected = state.controlTransport === "wireless_mqtt";
|
||
const ch1DriverOnly = isDriverOnlyCh1Runtime();
|
||
$("#simulationFaultField").hidden = !simulatedHardware;
|
||
$("#feedbackModeLabel").textContent = hardware
|
||
? (ch1DriverOnly
|
||
? (mqttSelected
|
||
? "MQTT CH1 · AUTHORITATIVE CAN FEEDBACK"
|
||
: "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK")
|
||
: "T2G BENCH · PHYSICAL CAN FEEDBACK")
|
||
: simulatedHardware
|
||
? "SIMULATED HIL · FAULT INJECTION"
|
||
: "MATRIX-BACKED · SIMULATED FEEDBACK";
|
||
const runButtonLabel = $("#runButton span");
|
||
if (runButtonLabel) {
|
||
runButtonLabel.textContent = hardware
|
||
? "真实执行"
|
||
: simulatedHardware ? "运行故障仿真" : "生成并执行";
|
||
}
|
||
if (remember && mode === "hardware" && !hardwareExecutionReady) {
|
||
showToast(`已切换到真实下发;当前不可执行:${hardwareExecutionIssue()}`);
|
||
}
|
||
updateSeatFeedbackModeLabel();
|
||
refreshIcons();
|
||
return true;
|
||
}
|
||
|
||
function vehicleProfiles() {
|
||
return Array.isArray(state.runtime?.vehicles) ? state.runtime.vehicles : [];
|
||
}
|
||
|
||
function findVehicleProfile(vehicleId) {
|
||
return vehicleProfiles().find((vehicle) => vehicle.id === vehicleId)
|
||
|| state.runtime?.vehicle
|
||
|| null;
|
||
}
|
||
|
||
function applyTwinAngleRanges(vehicle) {
|
||
Object.keys(TWIN_ANGLE_RANGES).forEach((axis) => delete TWIN_ANGLE_RANGES[axis]);
|
||
const motors = vehicle?.seat?.motors || {};
|
||
Object.entries(motors).forEach(([axis, motor]) => {
|
||
const display = motor?.display_angle;
|
||
if (!display) return;
|
||
const zero = Number(display.zero_deg);
|
||
const maximum = Number(display.max_deg);
|
||
if (Number.isFinite(zero) && Number.isFinite(maximum) && maximum > zero) {
|
||
TWIN_ANGLE_RANGES[axis] = [zero, maximum];
|
||
}
|
||
});
|
||
}
|
||
|
||
function applySeatMotorAvailability(vehicle) {
|
||
const motors = vehicle?.seat?.motors || {};
|
||
const availableAxes = new Set(Object.keys(motors));
|
||
state.availableSeatAxes = availableAxes;
|
||
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
|
||
const available = availableAxes.has(axis) || axis === "height";
|
||
$$(`[data-axis="${axis}"], [data-twin-axis="${axis}"]`)
|
||
.forEach((item) => {
|
||
item.classList.toggle("seat-axis-unavailable", !available);
|
||
item.setAttribute("aria-hidden", String(!available));
|
||
});
|
||
const twinLabel = {
|
||
longitudinal: "水平位置",
|
||
cushion_tilt: "坐垫俯仰",
|
||
height: "座椅高度",
|
||
backrest_recline: "靠背夹角",
|
||
legrest_height: "腿托夹角",
|
||
legrest_extension: "腿托伸缩",
|
||
}[axis];
|
||
$$(`[data-twin-axis="${axis}"] > span`)
|
||
.forEach((label) => { label.textContent = twinLabel; });
|
||
});
|
||
}
|
||
|
||
function renderSeatMotorRows(vehicle) {
|
||
$$(".seat-card .motion-state-grid").forEach((grid) => {
|
||
const seat = grid.closest(".seat-card")?.dataset.seat;
|
||
const motors = seat === "driver"
|
||
? [
|
||
["longitudinal", { name: "水平" }],
|
||
["height", { name: "高度" }],
|
||
["backrest_recline", { name: "靠背" }],
|
||
["cushion_tilt", { name: "盆倾" }],
|
||
]
|
||
: [
|
||
["longitudinal", { name: "前后" }],
|
||
["cushion_tilt", { name: "高度" }],
|
||
["backrest_recline", { name: "靠背" }],
|
||
["legrest_height", { name: "腿托" }],
|
||
];
|
||
const fragment = document.createDocumentFragment();
|
||
motors.forEach(([axis, motor]) => {
|
||
const item = document.createElement("div");
|
||
item.className = "motion-axis";
|
||
item.dataset.axis = axis;
|
||
const label = document.createElement("span");
|
||
label.textContent = seat === "passenger" && axis === "cushion_tilt"
|
||
? "高度"
|
||
: {
|
||
longitudinal: "水平", cushion_tilt: "坐垫", height: "高度",
|
||
backrest_recline: "靠背", legrest_height: "腿托", legrest_extension: "伸缩",
|
||
}[axis] || motor?.name || axis;
|
||
const value = document.createElement("b");
|
||
value.textContent = "--";
|
||
item.append(label, value, document.createElement("i"));
|
||
fragment.append(item);
|
||
});
|
||
grid.replaceChildren(fragment);
|
||
});
|
||
}
|
||
|
||
function selectVehicle(vehicleId, { silent = false } = {}) {
|
||
const vehicle = findVehicleProfile(vehicleId);
|
||
if (!vehicle) return;
|
||
state.vehicleId = vehicle.id;
|
||
const select = $("#vehicleSelect");
|
||
if (select) select.value = vehicle.id;
|
||
applyTwinAngleRanges(vehicle);
|
||
renderSeatMotorRows(vehicle);
|
||
applySeatMotorAvailability(vehicle);
|
||
const longitudinal = vehicle.seat?.motors?.longitudinal;
|
||
const backrest = vehicle.seat?.motors?.backrest_recline;
|
||
$("#vehicleNote").textContent = longitudinal && backrest
|
||
? `${vehicle.name} · 水平 ${longitudinal.range}${longitudinal.unit} · 靠背 ${backrest.range}${backrest.unit}`
|
||
: `${vehicle.name} · 已加载车型配置`;
|
||
if (isDriverOnlyCh1Runtime()) {
|
||
if (state.vehicleStateSnapshot?.actuatorState) {
|
||
renderSeatState(state.vehicleStateSnapshot.actuatorState);
|
||
}
|
||
} else if (state.result?.actuator_state) {
|
||
renderSeatState(state.result.actuator_state);
|
||
} else if (state.seatTwin?.actuatorState) {
|
||
renderSeatState(state.seatTwin.actuatorState);
|
||
}
|
||
if (!silent) showToast(`已切换车型:${vehicle.name}`);
|
||
}
|
||
|
||
function populateVehicleSelector() {
|
||
const select = $("#vehicleSelect");
|
||
if (!select) return;
|
||
const vehicles = vehicleProfiles();
|
||
select.innerHTML = vehicles.length
|
||
? vehicles.map((vehicle) => `<option value="${vehicle.id}">${vehicle.name}</option>`).join("")
|
||
: '<option value="">未找到车型配置</option>';
|
||
const defaultVehicleId = state.runtime?.default_vehicle_id
|
||
|| state.runtime?.vehicle?.id
|
||
|| vehicles[0]?.id
|
||
|| "";
|
||
select.disabled = vehicles.length === 0;
|
||
if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true });
|
||
}
|
||
|
||
function modeProfiles() {
|
||
return Array.isArray(state.runtime?.modes) ? state.runtime.modes : [];
|
||
}
|
||
|
||
function modeChipLabel(mode) {
|
||
const name = String(mode?.name || mode?.id || "模式");
|
||
return name.endsWith("模式") && name.length > 2
|
||
? name.slice(0, -2)
|
||
: name;
|
||
}
|
||
|
||
function modeChipSummary(mode) {
|
||
const domains = Array.isArray(mode?.domains) ? mode.domains : [];
|
||
const summary = domains.slice(0, 2)
|
||
.map((domain) => MODE_DOMAIN_LABELS[domain] || domain)
|
||
.join(" · ");
|
||
if (summary) return summary;
|
||
return isUserModeProfile(mode)
|
||
? "自定义联动"
|
||
: String(mode?.description || "预设模式");
|
||
}
|
||
|
||
function modeSecondsLabel(durationMs) {
|
||
const seconds = Number(durationMs) / 1000;
|
||
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
|
||
}
|
||
|
||
const MODE_GALLERY_GROUPS = [
|
||
{
|
||
id: "custom",
|
||
title: "自定义模式",
|
||
description: "由用户创建或调整,可继续编辑与删除",
|
||
icon: "wand-sparkles",
|
||
preferredIds: [],
|
||
},
|
||
{
|
||
id: "static",
|
||
title: "静态模式",
|
||
description: "系统预设,一次执行并保持目标状态",
|
||
icon: "layout-grid",
|
||
preferredIds: [
|
||
"zero_gravity",
|
||
"office",
|
||
"nap",
|
||
"queen_passenger_driving",
|
||
"leisure_lounger",
|
||
"cruise_sofa",
|
||
"easy_entry_exit",
|
||
"electric_beach_chair",
|
||
"calf_decompression_float",
|
||
"baby_nap",
|
||
"meditation_space",
|
||
],
|
||
},
|
||
{
|
||
id: "dynamic",
|
||
title: "动态模式",
|
||
description: "按受控节拍持续变化,可随时停止或由保护时限结束",
|
||
icon: "waves",
|
||
preferredIds: ["rocking_horse", "dynamic_cradle"],
|
||
},
|
||
];
|
||
|
||
const LEGACY_DYNAMIC_MODE_IDS = new Set(["rocking_horse", "dynamic_cradle"]);
|
||
|
||
const MODE_DOMAIN_LABELS = {
|
||
seat: "座椅",
|
||
climate: "空调",
|
||
roof: "天幕",
|
||
ambient_light: "氛围灯",
|
||
interior_light: "座舱灯",
|
||
window: "车窗",
|
||
front_light: "车灯",
|
||
vehicle_status: "车辆状态",
|
||
};
|
||
|
||
const MODE_TARGET_LABELS = {
|
||
driver: "主驾",
|
||
passenger: "副驾",
|
||
front_left: "左前",
|
||
front_right: "右前 / 副驾",
|
||
rear_left: "左后",
|
||
rear_right: "右后",
|
||
front_row: "前排",
|
||
rear_row: "后排",
|
||
vehicle: "全车",
|
||
cabin: "全舱",
|
||
all: "全部",
|
||
};
|
||
|
||
const MODE_TIMELINE_TARGET_LABELS = {
|
||
longitudinal: "前后位置",
|
||
cushion_tilt: "坐垫角度",
|
||
backrest_recline: "靠背角度",
|
||
legrest_height: "腿托高度",
|
||
legrest_extension: "腿托伸缩",
|
||
};
|
||
|
||
function modeOperationParameters(operation) {
|
||
const parameters = operation?.parameters;
|
||
if (Array.isArray(parameters)) {
|
||
return parameters
|
||
.filter((item) => item && typeof item === "object" && item.name)
|
||
.map((item) => [String(item.name), item.value]);
|
||
}
|
||
if (!parameters || typeof parameters !== "object") return [];
|
||
return Object.entries(parameters);
|
||
}
|
||
|
||
function formatModeOperationValue(name, value) {
|
||
const valueLabels = {
|
||
on: "开启",
|
||
off: "关闭",
|
||
open: "打开",
|
||
close: "关闭",
|
||
stop: "停止",
|
||
internal: "内循环",
|
||
external: "外循环",
|
||
};
|
||
if (typeof value === "string" && valueLabels[value]) return valueLabels[value];
|
||
return formatConfigValue(name, value);
|
||
}
|
||
|
||
function modeOperationView(operation) {
|
||
const domain = String(operation?.domain || "");
|
||
const capability = String(operation?.capability || operation?.operation || "执行动作");
|
||
const scope = String(
|
||
operation?.target_scope
|
||
|| operation?.target?.instance
|
||
|| operation?.target?.zone
|
||
|| operation?.target
|
||
|| "vehicle",
|
||
);
|
||
const parameters = modeOperationParameters(operation)
|
||
.map(([name, value]) => {
|
||
const label = CONFIG_PARAMETER_LABELS[name] || name;
|
||
return `${label}:${formatModeOperationValue(name, value)}`;
|
||
})
|
||
.join(",");
|
||
return {
|
||
device: MODE_DOMAIN_LABELS[domain] || domain || "车端设备",
|
||
target: MODE_TARGET_LABELS[scope] || scope || "全车",
|
||
action: CONFIG_CAPABILITY_LABELS[capability] || capability,
|
||
parameters: parameters || "无附加参数",
|
||
};
|
||
}
|
||
|
||
function modeTimelineTargetSummary(targets) {
|
||
if (!targets || typeof targets !== "object") return "按模式目标运行";
|
||
return Object.entries(targets).map(([motor, target]) => {
|
||
const label = MODE_TIMELINE_TARGET_LABELS[motor] || motor;
|
||
if (!target || typeof target !== "object") return `${label}:${target}`;
|
||
if (target.position_pct != null) return `${label}:${target.position_pct}%`;
|
||
if (target.angle_deg != null) return `${label}:${target.angle_deg}°`;
|
||
if (target.hall_pulse != null) return `${label}:${target.hall_pulse} Hall`;
|
||
return `${label}:${Object.values(target).join(" / ")}`;
|
||
}).join(",");
|
||
}
|
||
|
||
function modeTimelineStepView(step, action) {
|
||
const timeout = Number(step?.timeout_ms || 0);
|
||
const duration = Number(step?.duration_ms || 0);
|
||
const timing = duration > 0
|
||
? `持续 ${modeSecondsLabel(duration)}s`
|
||
: timeout > 0
|
||
? `目标到位,保护上限 ${modeSecondsLabel(timeout)}s`
|
||
: "目标到位";
|
||
return {
|
||
device: "座椅",
|
||
target: "副驾",
|
||
action,
|
||
parameters: `${modeTimelineTargetSummary(step?.targets)};${timing}`,
|
||
};
|
||
}
|
||
|
||
function modeExecutionActionViews(mode) {
|
||
const operations = Array.isArray(mode?.operations)
|
||
? mode.operations.map(modeOperationView)
|
||
: [];
|
||
const timeline = mode?.timeline;
|
||
if (!timeline || typeof timeline !== "object") return operations;
|
||
const timelineSteps = [];
|
||
if (timeline.entry?.targets) {
|
||
timelineSteps.push(modeTimelineStepView(timeline.entry, "进入动态模式初始位置"));
|
||
}
|
||
if (Array.isArray(timeline.phases)) {
|
||
timeline.phases.forEach((phase, index) => {
|
||
timelineSteps.push(modeTimelineStepView(
|
||
phase,
|
||
`循环阶段 ${index + 1}:${phase?.name || phase?.id || "动态调节"}`,
|
||
));
|
||
});
|
||
}
|
||
return [...operations, ...timelineSteps];
|
||
}
|
||
|
||
function isUserModeProfile(mode) {
|
||
return mode?.origin === "user" || mode?.deletable === true;
|
||
}
|
||
|
||
function isDynamicModeProfile(mode) {
|
||
const behavior = String(mode?.behavior || "").trim().toLowerCase();
|
||
if (["cyclic", "dynamic"].includes(behavior)) return true;
|
||
if (behavior === "static") return false;
|
||
if (mode?.timeline && typeof mode.timeline === "object") return true;
|
||
return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || ""));
|
||
}
|
||
|
||
function orderModeGalleryProfiles(modes, preferredIds) {
|
||
const preferredOrder = new Map(preferredIds.map((modeId, index) => [modeId, index]));
|
||
return modes
|
||
.map((mode, sourceIndex) => ({ mode, sourceIndex }))
|
||
.sort((left, right) => {
|
||
const leftUser = isUserModeProfile(left.mode);
|
||
const rightUser = isUserModeProfile(right.mode);
|
||
if (leftUser !== rightUser) return leftUser ? -1 : 1;
|
||
const leftRank = preferredOrder.get(left.mode?.id) ?? Number.MAX_SAFE_INTEGER;
|
||
const rightRank = preferredOrder.get(right.mode?.id) ?? Number.MAX_SAFE_INTEGER;
|
||
return leftRank - rightRank || left.sourceIndex - right.sourceIndex;
|
||
})
|
||
.map(({ mode }) => mode);
|
||
}
|
||
|
||
function modeGalleryBuckets() {
|
||
const modes = modeProfiles();
|
||
return MODE_GALLERY_GROUPS.map((definition) => ({
|
||
...definition,
|
||
modes: orderModeGalleryProfiles(
|
||
modes.filter((mode) => (
|
||
definition.id === "custom"
|
||
? isUserModeProfile(mode)
|
||
: definition.id === "dynamic"
|
||
? !isUserModeProfile(mode) && isDynamicModeProfile(mode)
|
||
: !isUserModeProfile(mode) && !isDynamicModeProfile(mode)
|
||
)),
|
||
definition.preferredIds,
|
||
),
|
||
}));
|
||
}
|
||
|
||
function createModeGalleryCard(mode) {
|
||
const shell = document.createElement("article");
|
||
shell.className = "mode-gallery-card-shell";
|
||
shell.dataset.modeOrigin = mode?.origin || "preset";
|
||
const button = document.createElement("button");
|
||
button.className = "mode-gallery-card";
|
||
button.type = "button";
|
||
button.dataset.modeProfileId = mode.id;
|
||
button.dataset.modeCommand = `进入${mode.name}`;
|
||
button.dataset.modeOrigin = mode?.origin || "preset";
|
||
button.dataset.modeState = "idle";
|
||
button.setAttribute("aria-pressed", "false");
|
||
|
||
const iconBox = document.createElement("span");
|
||
iconBox.className = "mode-gallery-card-icon";
|
||
const icon = document.createElement("i");
|
||
icon.dataset.lucide = mode.icon || "bookmark";
|
||
iconBox.append(icon);
|
||
|
||
const copy = document.createElement("span");
|
||
copy.className = "mode-gallery-card-copy";
|
||
const heading = document.createElement("span");
|
||
heading.className = "mode-gallery-card-heading";
|
||
const name = document.createElement("strong");
|
||
name.textContent = mode.name || mode.id;
|
||
const stateLabel = document.createElement("small");
|
||
stateLabel.className = "mode-gallery-state";
|
||
stateLabel.textContent = "点击执行";
|
||
heading.append(name, stateLabel);
|
||
const description = document.createElement("span");
|
||
description.className = "mode-gallery-card-description";
|
||
description.textContent = mode.description || "整车场景联动配置";
|
||
const meta = document.createElement("span");
|
||
meta.className = "mode-gallery-card-meta";
|
||
const domains = Array.isArray(mode.domains) ? mode.domains : [];
|
||
const domainCopy = domains.slice(0, 4)
|
||
.map((domain) => MODE_DOMAIN_LABELS[domain] || domain)
|
||
.join(" · ");
|
||
meta.textContent = domainCopy || `${Number(mode.operation_count || 0)} 项联动动作`;
|
||
copy.append(heading, description, meta);
|
||
button.append(iconBox, copy);
|
||
shell.append(button);
|
||
|
||
const editButton = document.createElement("button");
|
||
editButton.className = "mode-gallery-edit";
|
||
editButton.type = "button";
|
||
editButton.dataset.modeEdit = mode.id;
|
||
editButton.title = `编辑${mode.name}`;
|
||
editButton.setAttribute("aria-label", `编辑功能模式${mode.name}`);
|
||
const editIcon = document.createElement("i");
|
||
editIcon.dataset.lucide = "pencil";
|
||
editButton.append(editIcon);
|
||
shell.append(editButton);
|
||
|
||
if (isUserModeProfile(mode)) {
|
||
const deleteButton = document.createElement("button");
|
||
deleteButton.className = "mode-gallery-delete";
|
||
deleteButton.type = "button";
|
||
deleteButton.dataset.modeDelete = mode.id;
|
||
const presetOverride = mode.preset_override === true;
|
||
deleteButton.title = presetOverride ? `恢复${mode.name}的预设配置` : `删除${mode.name}`;
|
||
deleteButton.setAttribute(
|
||
"aria-label",
|
||
presetOverride ? `恢复功能模式${mode.name}的预设配置` : `删除自定义功能模式${mode.name}`,
|
||
);
|
||
const deleteIcon = document.createElement("i");
|
||
deleteIcon.dataset.lucide = "trash-2";
|
||
deleteButton.append(deleteIcon);
|
||
shell.append(deleteButton);
|
||
}
|
||
return shell;
|
||
}
|
||
|
||
function createModeGalleryAddCard() {
|
||
const shell = document.createElement("article");
|
||
shell.className = "mode-gallery-card-shell mode-gallery-add-card-shell";
|
||
const button = document.createElement("button");
|
||
button.className = "mode-gallery-card mode-gallery-add-card";
|
||
button.type = "button";
|
||
button.dataset.modeCreate = "true";
|
||
|
||
const iconBox = document.createElement("span");
|
||
iconBox.className = "mode-gallery-card-icon";
|
||
const icon = document.createElement("i");
|
||
icon.dataset.lucide = "bookmark-plus";
|
||
icon.dataset.icon = "inline-start";
|
||
iconBox.append(icon);
|
||
|
||
const copy = document.createElement("span");
|
||
copy.className = "mode-gallery-card-copy";
|
||
const heading = document.createElement("span");
|
||
heading.className = "mode-gallery-card-heading";
|
||
const name = document.createElement("strong");
|
||
name.textContent = "用 AI 新建";
|
||
const stateLabel = document.createElement("small");
|
||
stateLabel.className = "mode-gallery-state";
|
||
stateLabel.textContent = "点击创建";
|
||
heading.append(name, stateLabel);
|
||
const description = document.createElement("span");
|
||
description.className = "mode-gallery-card-description";
|
||
description.textContent = "在 AI助手中描述需要联动的车辆功能";
|
||
const meta = document.createElement("span");
|
||
meta.className = "mode-gallery-card-meta";
|
||
meta.textContent = "解析 · 校验 · 生成控制指令";
|
||
copy.append(heading, description, meta);
|
||
button.append(iconBox, copy);
|
||
shell.append(button);
|
||
return shell;
|
||
}
|
||
|
||
function createModeGalleryEmpty(group) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "mode-gallery-empty";
|
||
empty.dataset.modeGroupEmpty = group.id;
|
||
empty.setAttribute("role", "status");
|
||
const icon = document.createElement("i");
|
||
icon.dataset.lucide = group.id === "custom"
|
||
? "wand-sparkles"
|
||
: group.id === "dynamic"
|
||
? "activity"
|
||
: "layout-grid";
|
||
const copy = document.createElement("span");
|
||
const title = document.createElement("strong");
|
||
title.textContent = `暂无${group.title}`;
|
||
const description = document.createElement("small");
|
||
description.textContent = group.id === "custom"
|
||
? "可通过 AI助手创建新的功能模式"
|
||
: group.id === "dynamic"
|
||
? "支持持续变化的系统模式会显示在这里"
|
||
: "系统静态预设会显示在这里";
|
||
copy.append(title, description);
|
||
empty.append(icon, copy);
|
||
return empty;
|
||
}
|
||
|
||
function populateModeGallery() {
|
||
const container = $("#modeGalleryGroups");
|
||
if (!container) return;
|
||
container.replaceChildren();
|
||
modeGalleryBuckets().forEach((group) => {
|
||
const section = document.createElement("section");
|
||
section.className = "mode-gallery-group";
|
||
section.dataset.modeGroup = group.id;
|
||
const headingId = `modeGalleryGroup-${group.id}`;
|
||
section.setAttribute("aria-labelledby", headingId);
|
||
const header = document.createElement("header");
|
||
const title = document.createElement("span");
|
||
const titleIcon = document.createElement("i");
|
||
titleIcon.dataset.lucide = group.icon;
|
||
const titleCopy = document.createElement("span");
|
||
const strong = document.createElement("strong");
|
||
strong.id = headingId;
|
||
strong.textContent = group.title;
|
||
const small = document.createElement("small");
|
||
small.textContent = group.description;
|
||
titleCopy.append(strong, small);
|
||
title.append(titleIcon, titleCopy);
|
||
const count = document.createElement("b");
|
||
count.textContent = String(group.modes.length);
|
||
header.append(title, count);
|
||
section.append(header);
|
||
|
||
const grid = document.createElement("div");
|
||
grid.className = "mode-gallery-grid";
|
||
if (group.id === "custom") grid.append(createModeGalleryAddCard());
|
||
group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
|
||
if (!group.modes.length) grid.append(createModeGalleryEmpty(group));
|
||
section.append(grid);
|
||
container.append(section);
|
||
});
|
||
const modeCount = modeProfiles().length;
|
||
$("#modeGalleryCount").textContent = String(modeCount);
|
||
$("#modeGallerySummaryCount").textContent = String(modeCount);
|
||
refreshIcons();
|
||
}
|
||
|
||
function setModeRailExpanded(expanded) {
|
||
const toggle = $("#modeRailToggle");
|
||
const content = $("#modeRailContent");
|
||
if (!toggle || !content) return;
|
||
const open = expanded === true;
|
||
toggle.setAttribute("aria-expanded", String(open));
|
||
toggle.setAttribute("aria-label", open ? "收起功能模式" : "展开功能模式");
|
||
toggle.title = open ? "收起功能模式" : "展开功能模式";
|
||
content.hidden = !open;
|
||
$(".mode-rail")?.classList.toggle("is-collapsed", !open);
|
||
if (!open) hideModeActionPopover({ immediate: true });
|
||
const icon = $("i", toggle);
|
||
if (icon) icon.dataset.lucide = open ? "chevron-up" : "chevron-down";
|
||
refreshIcons();
|
||
}
|
||
|
||
function setModeRailSelection(mode) {
|
||
const selection = $("#modeRailSelection");
|
||
if (selection && mode) selection.textContent = String(mode.name || mode.id || "静态模式");
|
||
}
|
||
|
||
function revealModeProfile(modeProfileId, { focus = false } = {}) {
|
||
if (!modeProfileId) return false;
|
||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||
const button = $(`.mode-chips button[data-mode-profile-id="${CSS.escape(modeProfileId)}"]`);
|
||
if (!button) return false;
|
||
setModeRailExpanded(true);
|
||
setModeRailSelection(mode);
|
||
window.clearTimeout(state.modeHighlightTimer);
|
||
$$(".mode-chips .just-created").forEach((item) => item.classList.remove("just-created"));
|
||
button.classList.add("just-created");
|
||
button.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
|
||
if (focus) button.focus({ preventScroll: true });
|
||
state.modeHighlightTimer = window.setTimeout(() => {
|
||
button.classList.remove("just-created");
|
||
state.modeHighlightTimer = null;
|
||
}, 4800);
|
||
return true;
|
||
}
|
||
|
||
function refreshModeProfileFromResult(result) {
|
||
const profile = result?.mode_profile;
|
||
const savedModeExecution = result?.orchestration_route === "saved_mode_command_flow";
|
||
if (!profile?.id || savedModeExecution) return;
|
||
const refreshKey = `${result.request_id || "mode"}:${profile.id}`;
|
||
if (state.latestModeRefreshKey === refreshKey) return;
|
||
state.latestModeRefreshKey = refreshKey;
|
||
void loadRuntime()
|
||
.then(() => revealModeProfile(profile.id))
|
||
.catch(() => {});
|
||
}
|
||
|
||
function openModeGallery() {
|
||
const backdrop = $("#modeGalleryBackdrop");
|
||
if (!backdrop) return;
|
||
backdrop.hidden = false;
|
||
document.body.classList.add("mode-gallery-open");
|
||
$("#modeGalleryToggle").setAttribute("aria-expanded", "true");
|
||
window.requestAnimationFrame(() => {
|
||
backdrop.classList.add("open");
|
||
$("#modeGalleryClose").focus();
|
||
});
|
||
syncReasoningControls();
|
||
}
|
||
|
||
function availableReasoningEfforts() {
|
||
const configured = state.runtime?.openai_reasoning_efforts;
|
||
return Array.isArray(configured) && configured.length ? configured : ["low", "medium", "high"];
|
||
}
|
||
|
||
function selectReasoningEffort(effort, { persist = true } = {}) {
|
||
if (!availableReasoningEfforts().includes(effort)) return false;
|
||
state.reasoningEffort = effort;
|
||
if (persist) localStorage.setItem("cognitive-agent.reasoning-effort", effort);
|
||
syncReasoningControls();
|
||
return true;
|
||
}
|
||
|
||
function syncReasoningControls() {
|
||
const enabled = state.provider === "openai" && state.runtime?.openai_available === true;
|
||
const descriptions = {
|
||
low: "极速模式优先降低指令解析延迟",
|
||
medium: "均衡模式兼顾自然语言解析精度与响应速度",
|
||
high: "深度模式适合多设备、相对动作与复杂约束指令",
|
||
};
|
||
$$('[data-reasoning-effort]').forEach((button) => {
|
||
button.disabled = !enabled || !availableReasoningEfforts().includes(button.dataset.reasoningEffort);
|
||
button.classList.toggle("active", button.dataset.reasoningEffort === state.reasoningEffort);
|
||
});
|
||
const note = $("#reasoningNote");
|
||
if (note) note.textContent = enabled ? descriptions[state.reasoningEffort] : "本地规则解析不调用模型推理";
|
||
}
|
||
|
||
function closeModeGallery({ restoreFocus = true } = {}) {
|
||
const backdrop = $("#modeGalleryBackdrop");
|
||
if (!backdrop || backdrop.hidden) return;
|
||
backdrop.classList.remove("open");
|
||
backdrop.hidden = true;
|
||
document.body.classList.remove("mode-gallery-open");
|
||
$("#modeGalleryToggle").setAttribute("aria-expanded", "false");
|
||
if (restoreFocus) $("#modeGalleryToggle").focus();
|
||
}
|
||
|
||
function modeEditorCommandCount(flow) {
|
||
const plans = flow?.plans && typeof flow.plans === "object" ? flow.plans : {};
|
||
return Object.values(plans).reduce(
|
||
(count, plan) => count + (Array.isArray(plan?.spec?.commands) ? plan.spec.commands.length : 0),
|
||
0,
|
||
);
|
||
}
|
||
|
||
function modeEditorFlowFromResult(result) {
|
||
const plans = result?.command_plans && typeof result.command_plans === "object"
|
||
? result.command_plans
|
||
: {};
|
||
const functionConfigs = result?.function_configs && typeof result.function_configs === "object"
|
||
? result.function_configs
|
||
: {};
|
||
return {
|
||
schema: "cognitive-agent-mode-command-flow/v1",
|
||
source_request_id: result?.request_id || "",
|
||
execution_mode: result?.execution_mode || "",
|
||
control_transport: result?.control_transport || "",
|
||
function_configs: functionConfigs,
|
||
plans,
|
||
execution_steps: [],
|
||
};
|
||
}
|
||
|
||
function modeEditorCommandCategory(command) {
|
||
const value = String(command || "").trim();
|
||
if (/^ac temp\b/.test(value)) return "空调温度";
|
||
if (/^ac fan\b/.test(value)) return "空调风量";
|
||
if (/^ac (?:on|off)$/.test(value)) return "空调电源";
|
||
if (/^ac mode\b/.test(value)) return "出风模式";
|
||
if (/^ac control\b/.test(value)) return "空调控制方式";
|
||
if (/^ac cycle\b/.test(value)) return "空调循环";
|
||
if (/^window\b/.test(value)) return value.includes(" position ") ? "单窗位置" : "车窗动作";
|
||
if (/^shade\b/.test(value)) return "遮阳帘";
|
||
if (/^light ambient\b/.test(value)) return "氛围灯";
|
||
if (/^light reading\b/.test(value)) return "全部阅读灯";
|
||
if (/^light indoor\b/.test(value)) return "室内灯总开关";
|
||
if (/^seat passenger\b/.test(value)) return "副驾座椅控制";
|
||
if (/^seat driver\b/.test(value)) return "主驾座椅控制";
|
||
if (/^outlet\b/.test(value)) return "电动风口";
|
||
if (/^door rear\b/.test(value)) return "后排门锁";
|
||
if (/^door vehicle\b/.test(value)) return "整车门锁";
|
||
return "CAN 控制";
|
||
}
|
||
|
||
function modeEditorPlanCommands(plan, operationRef = "") {
|
||
const commands = Array.isArray(plan?.spec?.commands) ? plan.spec.commands : [];
|
||
if (!operationRef) return commands;
|
||
return commands.filter((item) => String(item?.operation_ref || "")
|
||
.split(",")
|
||
.map((value) => value.trim())
|
||
.includes(operationRef));
|
||
}
|
||
|
||
function modeEditorCanId(value) {
|
||
const normalized = String(value || "").trim().replace(/^0x/i, "").toUpperCase();
|
||
return normalized ? `0x${normalized}` : "";
|
||
}
|
||
|
||
function modeEditorFrameFromPlan(command, readable) {
|
||
const signals = command?.signals || {};
|
||
const can0Frame = String(readable?.frame || "").match(/^(0x[0-9A-F]+)\s*·\s*(.+)$/i);
|
||
const frameId = modeEditorCanId(signals.frame_id || can0Frame?.[1]);
|
||
const dataHex = String(signals.data_hex || can0Frame?.[2] || "").toUpperCase();
|
||
if (!frameId || !dataHex) return null;
|
||
const channelIndex = readable?.channel === "CAN0" ? 0 : 1;
|
||
const isFd = Boolean(signals.is_fd);
|
||
return {
|
||
frame_id: frameId,
|
||
data_hex: dataHex,
|
||
channel: channelIndex,
|
||
is_fd: isFd,
|
||
bitrate_switch: Boolean(signals.bitrate_switch),
|
||
};
|
||
}
|
||
|
||
function modeEditorNormalizeFrame(frame, readable) {
|
||
if (!frame || typeof frame !== "object") return null;
|
||
const frameId = modeEditorCanId(frame.frame_id || frame.canId);
|
||
const dataHex = String(frame.data_hex || frame.data || "").toUpperCase();
|
||
if (!frameId || !dataHex) return null;
|
||
const rawChannel = frame.channel;
|
||
const channel = typeof rawChannel === "string"
|
||
? Number(rawChannel.replace(/^can/i, ""))
|
||
: Number(rawChannel ?? (readable?.channel === "CAN0" ? 0 : 1));
|
||
const isFd = frame.is_fd === true || frame.frameType === "fd" || frame.frame_format === "can_fd";
|
||
return {
|
||
frame_id: frameId,
|
||
data_hex: dataHex,
|
||
channel: Number.isInteger(channel) ? channel : readable?.channel === "CAN0" ? 0 : 1,
|
||
is_fd: isFd,
|
||
bitrate_switch: Boolean(frame.bitrate_switch ?? frame.brs),
|
||
};
|
||
}
|
||
|
||
function modeEditorMqttEnvelope(frame, command, requestId = "<运行时生成>") {
|
||
const topic = state.runtime?.vehicle_mqtt?.control_topic || "n7/n7-headunit/can/command";
|
||
return {
|
||
topic,
|
||
qos: Number(state.runtime?.vehicle_mqtt?.qos ?? 1),
|
||
retain: false,
|
||
payload: {
|
||
requestId,
|
||
command,
|
||
channel: `can${frame.channel}`,
|
||
canId: frame.frame_id,
|
||
frameType: frame.is_fd ? "fd" : "classic",
|
||
brs: frame.bitrate_switch,
|
||
extended: false,
|
||
remote: false,
|
||
data: frame.data_hex,
|
||
repeat: 1,
|
||
intervalMs: 0,
|
||
responseTimeoutMs: Number(state.runtime?.vehicle_mqtt?.feedback_timeout_ms ?? 1500),
|
||
},
|
||
};
|
||
}
|
||
|
||
function modeEditorMessagesForOperation(flow, readable, operationRef, plan) {
|
||
const steps = Array.isArray(flow?.execution_steps) ? flow.execution_steps : [];
|
||
const matchingSteps = steps.filter((item) => String(item?.ref || "") === operationRef);
|
||
const actualMessages = matchingSteps.flatMap((item) => {
|
||
const telemetry = item?.telemetry && typeof item.telemetry === "object" ? item.telemetry : item;
|
||
return Array.isArray(telemetry?.mqtt_messages) ? telemetry.mqtt_messages : [];
|
||
});
|
||
if (actualMessages.length) {
|
||
return actualMessages.map((message, index) => ({
|
||
sequence: index + 1,
|
||
offset_ms: null,
|
||
source: "published",
|
||
...message,
|
||
}));
|
||
}
|
||
|
||
let frames = matchingSteps.flatMap((item) => {
|
||
const telemetry = item?.telemetry && typeof item.telemetry === "object" ? item.telemetry : item;
|
||
const sentFrames = Array.isArray(telemetry?.sent_frames) ? telemetry.sent_frames : [];
|
||
if (sentFrames.length) return sentFrames;
|
||
return telemetry?.command_frame ? [telemetry.command_frame] : [];
|
||
}).map((frame) => modeEditorNormalizeFrame(frame, readable)).filter(Boolean);
|
||
|
||
const planMessages = [];
|
||
if (!frames.length) {
|
||
modeEditorPlanCommands(plan, operationRef).forEach((command) => {
|
||
const frame = modeEditorFrameFromPlan(command, readable);
|
||
if (!frame) return;
|
||
let special = null;
|
||
try {
|
||
special = JSON.parse(String(command?.signals?.special_parameters || "null"));
|
||
} catch (_error) {
|
||
special = null;
|
||
}
|
||
if (command?.signals?.special_handler === "driver_seat_memory_recall"
|
||
&& Array.isArray(special?.frame_sequence)) {
|
||
const baseOffset = Math.max(0, Number(command?.send_offset_ms || 0));
|
||
const period = Math.max(0, Number(special.period_ms || 0));
|
||
special.frame_sequence.forEach((dataHex, index) => {
|
||
planMessages.push({
|
||
frame: { ...frame, data_hex: String(dataHex).toUpperCase() },
|
||
offset_ms: baseOffset + (index * period),
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
const repeat = Math.max(1, Number(command?.repeat?.count || 1));
|
||
const period = Math.max(0, Number(command?.repeat?.period_ms || 0));
|
||
const baseOffset = Math.max(0, Number(command?.send_offset_ms || 0));
|
||
for (let index = 0; index < repeat; index += 1) {
|
||
planMessages.push({ frame, offset_ms: baseOffset + (index * period) });
|
||
}
|
||
});
|
||
} else {
|
||
frames = frames.map((frame, index) => ({ frame, offset_ms: index === 0 ? 0 : null }));
|
||
}
|
||
|
||
const candidates = frames.length ? frames : planMessages;
|
||
return candidates.map((item, index) => {
|
||
const base = {
|
||
sequence: index + 1,
|
||
offset_ms: item.offset_ms,
|
||
source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can",
|
||
};
|
||
return flow?.control_transport === "wireless_mqtt"
|
||
? { ...base, ...modeEditorMqttEnvelope(item.frame, readable.command) }
|
||
: { ...base, frame: item.frame };
|
||
});
|
||
}
|
||
|
||
function modeEditorReadableCommands(flow) {
|
||
if (!flow) return [];
|
||
const plans = flow.plans && typeof flow.plans === "object" ? flow.plans : {};
|
||
const configurations = flow.function_configs && typeof flow.function_configs === "object"
|
||
? flow.function_configs
|
||
: {};
|
||
const converter = window.CognitiveAgentConsoleV2?.getParsedCanCommands;
|
||
const commands = [];
|
||
|
||
if (typeof converter === "function") {
|
||
Object.entries(configurations).forEach(([key, configuration]) => {
|
||
const plan = plans[key];
|
||
if (!plan) return;
|
||
(configuration?.spec?.operations || []).forEach((operation) => {
|
||
const singleConfig = {
|
||
...configuration,
|
||
spec: { ...configuration.spec, operations: [operation] },
|
||
};
|
||
const readable = converter({
|
||
function_configs: { [key]: singleConfig },
|
||
command_plans: { [key]: plan },
|
||
})[0];
|
||
if (!readable) return;
|
||
commands.push({
|
||
...readable,
|
||
operation_ref: String(operation.id || ""),
|
||
messages: modeEditorMessagesForOperation(
|
||
flow,
|
||
readable,
|
||
String(operation.id || ""),
|
||
plan,
|
||
),
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
if (!commands.length) {
|
||
Object.values(plans).forEach((plan) => {
|
||
modeEditorPlanCommands(plan).forEach((item) => {
|
||
const debugCommand = String(item?.signals?.debug_command || item?.message || "CAN frame");
|
||
const channel = new Set(["0x29E", "0x300", "0x3CE"]).has(
|
||
modeEditorCanId(item?.signals?.frame_id),
|
||
)
|
||
? "CAN0"
|
||
: "CAN1";
|
||
const readable = { channel, category: modeEditorCommandCategory(debugCommand), command: debugCommand };
|
||
commands.push({
|
||
...readable,
|
||
operation_ref: String(item?.operation_ref || ""),
|
||
messages: modeEditorMessagesForOperation(
|
||
flow,
|
||
readable,
|
||
String(item?.operation_ref || ""),
|
||
plan,
|
||
),
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
const unique = new Map();
|
||
commands.forEach((item) => {
|
||
const key = `${item.channel}:${item.command}`;
|
||
if (!unique.has(key)) unique.set(key, item);
|
||
else unique.get(key).messages.push(...item.messages);
|
||
});
|
||
return [...unique.values()];
|
||
}
|
||
|
||
function modeEditorAppendCommandCard(preview, item, flow) {
|
||
const card = document.createElement("details");
|
||
card.className = "mode-editor-command-card";
|
||
card.dataset.canChannel = item.channel;
|
||
const summary = document.createElement("summary");
|
||
const meta = document.createElement("span");
|
||
meta.className = "mode-editor-command-meta";
|
||
const channel = document.createElement("b");
|
||
channel.textContent = item.channel;
|
||
const category = document.createElement("strong");
|
||
category.textContent = item.category;
|
||
meta.append(channel, category);
|
||
const command = document.createElement("code");
|
||
command.textContent = item.command;
|
||
const chevron = document.createElement("i");
|
||
chevron.dataset.lucide = "chevron-down";
|
||
summary.append(meta, command, chevron);
|
||
card.append(summary);
|
||
|
||
const sequence = document.createElement("section");
|
||
sequence.className = "mode-editor-message-sequence";
|
||
const header = document.createElement("header");
|
||
const title = document.createElement("span");
|
||
title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列" : "CAN 帧序列(未经过 MQTT)";
|
||
const messageCount = document.createElement("b");
|
||
messageCount.textContent = `${item.messages.length} 条`;
|
||
header.append(title, messageCount);
|
||
sequence.append(header);
|
||
|
||
if (!item.messages.length) {
|
||
const empty = document.createElement("p");
|
||
empty.textContent = "该指令当前没有可展示的已编译帧。";
|
||
sequence.append(empty);
|
||
} else {
|
||
const list = document.createElement("ol");
|
||
item.messages.forEach((message) => {
|
||
const row = document.createElement("li");
|
||
const rowHeader = document.createElement("div");
|
||
const sequenceLabel = document.createElement("span");
|
||
const offset = Number.isFinite(message.offset_ms) ? ` · +${message.offset_ms}ms` : "";
|
||
sequenceLabel.textContent = `#${message.sequence}${offset}`;
|
||
const topic = document.createElement("code");
|
||
topic.textContent = message.topic || `can${message.frame?.channel ?? "-"}`;
|
||
rowHeader.append(sequenceLabel, topic);
|
||
const payload = document.createElement("pre");
|
||
payload.textContent = JSON.stringify(
|
||
message.payload
|
||
? { topic: message.topic, qos: message.qos, retain: message.retain, payload: message.payload }
|
||
: message.frame,
|
||
null,
|
||
2,
|
||
);
|
||
row.append(rowHeader, payload);
|
||
list.append(row);
|
||
});
|
||
sequence.append(list);
|
||
}
|
||
card.append(sequence);
|
||
preview.append(card);
|
||
}
|
||
|
||
function renderModeEditorFlow() {
|
||
const editor = state.modeEditor;
|
||
const preview = $("#modeEditorFlowPreview");
|
||
const count = $("#modeEditorFlowCount");
|
||
const complete = $("#modeEditorComplete");
|
||
if (!preview || !count || !complete) return;
|
||
const commandCount = modeEditorCommandCount(editor?.flow);
|
||
const readableCommands = modeEditorReadableCommands(editor?.flow);
|
||
const instructionMatches = Boolean(
|
||
editor?.executedInstruction
|
||
&& editor.executedInstruction === $("#modeEditorInstruction").value.trim(),
|
||
);
|
||
complete.disabled = !(commandCount > 0 && instructionMatches && editor?.sourceRequestId);
|
||
count.textContent = commandCount > 0
|
||
? `${readableCommands.length || commandCount} 条指令`
|
||
: "尚未生成";
|
||
preview.classList.toggle("empty", commandCount === 0);
|
||
preview.replaceChildren();
|
||
if (commandCount > 0 && readableCommands.length) {
|
||
readableCommands.forEach((item) => modeEditorAppendCommandCard(preview, item, editor.flow));
|
||
refreshIcons();
|
||
} else {
|
||
const empty = document.createElement("p");
|
||
empty.textContent = commandCount > 0
|
||
? "已生成 CAN 控制流,但当前指令没有可读映射。"
|
||
: "点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。";
|
||
preview.append(empty);
|
||
}
|
||
}
|
||
|
||
function setModeEditorFlowExpanded(expanded) {
|
||
const backdrop = $("#modeEditorBackdrop");
|
||
const dialog = backdrop?.querySelector(".mode-editor");
|
||
const button = $("#modeEditorFlowExpand");
|
||
if (!backdrop || !dialog || !button) return;
|
||
const nextExpanded = Boolean(expanded);
|
||
backdrop.classList.toggle("flow-expanded", nextExpanded);
|
||
dialog.classList.toggle("flow-expanded", nextExpanded);
|
||
button.setAttribute("aria-expanded", String(nextExpanded));
|
||
button.setAttribute(
|
||
"aria-label",
|
||
nextExpanded ? "还原控制指令区域" : "展开控制指令区域",
|
||
);
|
||
button.title = nextExpanded ? "还原控制指令区域" : "展开控制指令区域";
|
||
button.innerHTML = `<i data-lucide="${nextExpanded ? "minimize-2" : "maximize-2"}"></i>`;
|
||
refreshIcons();
|
||
}
|
||
|
||
function toggleModeEditorFlowExpanded() {
|
||
const button = $("#modeEditorFlowExpand");
|
||
setModeEditorFlowExpanded(button?.getAttribute("aria-expanded") !== "true");
|
||
}
|
||
|
||
function openModeEditor(modeProfileId) {
|
||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||
const backdrop = $("#modeEditorBackdrop");
|
||
if (!mode || !backdrop) return;
|
||
const instruction = String(mode.instruction || `进入${mode.name}`).trim();
|
||
const flow = mode.command_flow && typeof mode.command_flow === "object"
|
||
? mode.command_flow
|
||
: null;
|
||
state.modeEditor = {
|
||
modeId: mode.id,
|
||
isNew: false,
|
||
sourceRequestId: String(flow?.source_request_id || ""),
|
||
executedInstruction: flow ? instruction : "",
|
||
flow,
|
||
restoreSelector: `[data-mode-edit="${CSS.escape(mode.id)}"]`,
|
||
};
|
||
$("#modeEditorTitle").textContent = "编辑功能模式";
|
||
$("#modeEditorName").value = mode.name || "";
|
||
$("#modeEditorInstruction").value = instruction;
|
||
$("#modeEditorComplete").innerHTML = '<i data-lucide="save" data-icon="inline-start"></i><span>完成</span>';
|
||
$("#modeEditorStatus").textContent = flow
|
||
? "已载入永久保存的自然语言和控制指令流,可修改后重新生成。"
|
||
: "修改自然语言后需要重新生成指令,才能点击完成永久保存。";
|
||
setModeEditorFlowExpanded(false);
|
||
backdrop.hidden = false;
|
||
document.body.classList.add("mode-editor-open");
|
||
renderModeEditorFlow();
|
||
window.requestAnimationFrame(() => {
|
||
backdrop.classList.add("open");
|
||
$("#modeEditorName").focus();
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
function openModeEditorForCreate({ restoreSelector = "#saveModeToggle" } = {}) {
|
||
const backdrop = $("#modeEditorBackdrop");
|
||
if (!backdrop) return;
|
||
const instruction = $("#utterance")?.value.trim() || "";
|
||
state.modeEditor = {
|
||
modeId: null,
|
||
isNew: true,
|
||
sourceRequestId: "",
|
||
executedInstruction: "",
|
||
flow: null,
|
||
restoreSelector,
|
||
};
|
||
$("#modeEditorTitle").textContent = "新增功能模式";
|
||
$("#modeEditorName").value = "";
|
||
$("#modeEditorInstruction").value = instruction;
|
||
$("#modeEditorStatus").textContent = "输入模式名称和自然语言,生成控制指令后即可添加模式卡片。";
|
||
$("#modeEditorComplete").innerHTML = '<i data-lucide="bookmark-plus" data-icon="inline-start"></i><span>添加模式</span>';
|
||
setModeEditorFlowExpanded(false);
|
||
backdrop.hidden = false;
|
||
document.body.classList.add("mode-editor-open");
|
||
renderModeEditorFlow();
|
||
window.requestAnimationFrame(() => {
|
||
backdrop.classList.add("open");
|
||
(instruction ? $("#modeEditorName") : $("#modeEditorInstruction")).focus();
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
function startAssistantModeCreation({ restoreSelector = "#saveModeToggle" } = {}) {
|
||
closeModeGallery({ restoreFocus: false });
|
||
if (window.CognitiveAgentAssistant?.startModeCreation) {
|
||
window.CognitiveAgentAssistant.startModeCreation();
|
||
return;
|
||
}
|
||
openModeEditorForCreate({ restoreSelector });
|
||
}
|
||
|
||
function closeModeEditor({ restoreFocus = true } = {}) {
|
||
const backdrop = $("#modeEditorBackdrop");
|
||
const restoreSelector = state.modeEditor?.restoreSelector;
|
||
if (!backdrop || backdrop.hidden) return;
|
||
backdrop.classList.remove("open");
|
||
backdrop.hidden = true;
|
||
setModeEditorFlowExpanded(false);
|
||
document.body.classList.remove("mode-editor-open");
|
||
state.modeEditor = null;
|
||
if (restoreFocus && restoreSelector) $(restoreSelector)?.focus();
|
||
}
|
||
|
||
function invalidateModeEditorFlow() {
|
||
if (!state.modeEditor) return;
|
||
const instruction = $("#modeEditorInstruction").value.trim();
|
||
const current = instruction === state.modeEditor.executedInstruction;
|
||
$("#modeEditorStatus").textContent = current && modeEditorCommandCount(state.modeEditor.flow) > 0
|
||
? "当前自然语言已有控制指令流,可点击完成永久保存。"
|
||
: "自然语言已修改,请重新点击“生成指令”。";
|
||
renderModeEditorFlow();
|
||
}
|
||
|
||
async function executeModeEditorInstruction() {
|
||
const editor = state.modeEditor;
|
||
const instruction = $("#modeEditorInstruction").value.trim();
|
||
if (!editor || !instruction) {
|
||
showToast("请输入自然语言指令");
|
||
$("#modeEditorInstruction").focus();
|
||
return;
|
||
}
|
||
const execute = $("#modeEditorExecute");
|
||
execute.disabled = true;
|
||
execute.classList.add("loading");
|
||
execute.innerHTML = '<i data-lucide="loader-circle" data-icon="inline-start"></i><span>生成中…</span>';
|
||
$("#modeEditorComplete").disabled = true;
|
||
$("#modeEditorStatus").textContent = "正在复用主页解析流程生成控制指令;不会执行设备…";
|
||
refreshIcons();
|
||
try {
|
||
const { payload: result } = await requestNaturalLanguageControl(instruction, {
|
||
executionMode: "dry_run",
|
||
clientType: "mode_editor_compile",
|
||
previewOnly: true,
|
||
});
|
||
const flow = modeEditorFlowFromResult(result);
|
||
const commandCount = modeEditorCommandCount(flow);
|
||
if (!result || commandCount === 0) {
|
||
throw new Error("未生成可保存的控制指令流");
|
||
}
|
||
editor.flow = flow;
|
||
editor.sourceRequestId = result.request_id;
|
||
editor.executedInstruction = instruction;
|
||
$("#modeEditorStatus").textContent = `已生成 ${commandCount} 条控制指令;尚未执行设备。点击“完成”保存到模式卡片。`;
|
||
renderModeEditorFlow();
|
||
} catch (error) {
|
||
editor.flow = null;
|
||
editor.sourceRequestId = "";
|
||
editor.executedInstruction = "";
|
||
$("#modeEditorStatus").textContent = error.message || "控制指令流生成失败。";
|
||
renderModeEditorFlow();
|
||
showToast(error.message || "控制指令流生成失败");
|
||
} finally {
|
||
execute.disabled = false;
|
||
execute.classList.remove("loading");
|
||
execute.innerHTML = '<i data-lucide="wand-sparkles" data-icon="inline-start"></i><span>生成指令</span>';
|
||
refreshIcons();
|
||
}
|
||
}
|
||
|
||
async function completeModeEditor() {
|
||
const editor = state.modeEditor;
|
||
const name = $("#modeEditorName").value.trim();
|
||
const instruction = $("#modeEditorInstruction").value.trim();
|
||
if (!editor || !name) {
|
||
showToast("请输入模式名称");
|
||
$("#modeEditorName").focus();
|
||
return;
|
||
}
|
||
if (editor.isNew && !name.endsWith("模式") && name.length > 62) {
|
||
showToast("模式名称添加“模式”后不能超过 64 个字符");
|
||
$("#modeEditorName").focus();
|
||
return;
|
||
}
|
||
const modeName = editor.isNew && !name.endsWith("模式") ? `${name}模式` : name;
|
||
if (
|
||
!editor.sourceRequestId
|
||
|| editor.executedInstruction !== instruction
|
||
|| modeEditorCommandCount(editor.flow) === 0
|
||
) {
|
||
showToast("请先根据当前自然语言生成控制指令流");
|
||
return;
|
||
}
|
||
const complete = $("#modeEditorComplete");
|
||
complete.disabled = true;
|
||
complete.classList.add("loading");
|
||
complete.innerHTML = '<i data-lucide="loader-circle" data-icon="inline-start"></i><span>保存中…</span>';
|
||
refreshIcons();
|
||
try {
|
||
const endpoint = editor.isNew
|
||
? "/api/mode-profiles"
|
||
: `/api/mode-profiles/${encodeURIComponent(editor.modeId)}`;
|
||
const response = await apiRequest(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: modeName,
|
||
instruction,
|
||
source_request_id: editor.sourceRequestId,
|
||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||
}),
|
||
});
|
||
const payload = await response.json();
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
|
||
await loadRuntime();
|
||
closeModeEditor({ restoreFocus: false });
|
||
revealModeProfile(payload.mode_profile?.id, { focus: true });
|
||
if (editor.isNew) window.CognitiveAgentAssistant?.announceModeCreated?.(payload.mode_profile);
|
||
showToast(`${payload.mode_profile?.name || modeName}已永久保存到模式卡片`);
|
||
} catch (error) {
|
||
$("#modeEditorStatus").textContent = error.message || "模式保存失败。";
|
||
showToast(error.message || "模式保存失败");
|
||
renderModeEditorFlow();
|
||
} finally {
|
||
complete.classList.remove("loading");
|
||
complete.innerHTML = editor.isNew
|
||
? '<i data-lucide="bookmark-plus" data-icon="inline-start"></i><span>添加模式</span>'
|
||
: '<i data-lucide="save" data-icon="inline-start"></i><span>完成</span>';
|
||
refreshIcons();
|
||
}
|
||
}
|
||
|
||
function installModeChipScroller() {
|
||
const group = $(".mode-chips");
|
||
if (!group || group.dataset.modeScrollerBound === "true") return;
|
||
group.dataset.modeScrollerBound = "true";
|
||
if (!group.hasAttribute("tabindex")) group.tabIndex = 0;
|
||
group.setAttribute("aria-label", "快捷功能模式,可使用滚轮、拖动或方向键横向浏览");
|
||
|
||
const maximumScroll = () => Math.max(0, group.scrollWidth - group.clientWidth);
|
||
const updateScrollState = () => {
|
||
const maximum = maximumScroll();
|
||
group.classList.toggle("can-scroll-left", group.scrollLeft > 1);
|
||
group.classList.toggle("can-scroll-right", group.scrollLeft < maximum - 1);
|
||
group.classList.toggle("is-overflowing", maximum > 1);
|
||
};
|
||
const scrollBy = (amount) => {
|
||
const previous = group.scrollLeft;
|
||
const next = Math.max(0, Math.min(maximumScroll(), previous + amount));
|
||
if (Math.abs(next - previous) < 0.5) return false;
|
||
group.scrollLeft = next;
|
||
updateScrollState();
|
||
return true;
|
||
};
|
||
|
||
group.addEventListener("wheel", (event) => {
|
||
if (maximumScroll() <= 1) return;
|
||
const dominantDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||
? event.deltaX
|
||
: event.deltaY;
|
||
if (!dominantDelta) return;
|
||
const multiplier = event.deltaMode === 1
|
||
? 18
|
||
: event.deltaMode === 2
|
||
? group.clientWidth
|
||
: 1;
|
||
if (scrollBy(dominantDelta * multiplier)) event.preventDefault();
|
||
}, { passive: false });
|
||
|
||
group.addEventListener("keydown", (event) => {
|
||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||
const page = Math.max(120, group.clientWidth * 0.78);
|
||
let handled = false;
|
||
if (event.key === "ArrowLeft") handled = scrollBy(-132);
|
||
else if (event.key === "ArrowRight") handled = scrollBy(132);
|
||
else if (event.key === "PageUp") handled = scrollBy(-page);
|
||
else if (event.key === "PageDown") handled = scrollBy(page);
|
||
else if (event.key === "Home") handled = scrollBy(-maximumScroll());
|
||
else if (event.key === "End") handled = scrollBy(maximumScroll());
|
||
if (handled) event.preventDefault();
|
||
});
|
||
|
||
let pointerId = null;
|
||
let pointerStartX = 0;
|
||
let pointerStartScroll = 0;
|
||
let dragged = false;
|
||
let suppressClick = false;
|
||
let pointerCaptured = false;
|
||
group.addEventListener("pointerdown", (event) => {
|
||
if (event.pointerType !== "mouse" || event.button !== 0 || maximumScroll() <= 1) return;
|
||
pointerId = event.pointerId;
|
||
pointerStartX = event.clientX;
|
||
pointerStartScroll = group.scrollLeft;
|
||
dragged = false;
|
||
pointerCaptured = false;
|
||
});
|
||
group.addEventListener("pointermove", (event) => {
|
||
if (pointerId !== event.pointerId) return;
|
||
const distance = event.clientX - pointerStartX;
|
||
if (!dragged && Math.abs(distance) < 5) return;
|
||
dragged = true;
|
||
if (!pointerCaptured && typeof group.setPointerCapture === "function") {
|
||
group.setPointerCapture(pointerId);
|
||
pointerCaptured = true;
|
||
}
|
||
group.classList.add("is-dragging");
|
||
group.scrollLeft = Math.max(0, Math.min(maximumScroll(), pointerStartScroll - distance));
|
||
updateScrollState();
|
||
event.preventDefault();
|
||
});
|
||
const finishPointerDrag = (event) => {
|
||
if (pointerId !== event.pointerId) return;
|
||
if (pointerCaptured) group.releasePointerCapture?.(pointerId);
|
||
pointerId = null;
|
||
pointerCaptured = false;
|
||
group.classList.remove("is-dragging");
|
||
if (dragged) {
|
||
suppressClick = true;
|
||
window.setTimeout(() => { suppressClick = false; }, 0);
|
||
}
|
||
dragged = false;
|
||
};
|
||
group.addEventListener("pointerup", finishPointerDrag);
|
||
group.addEventListener("pointercancel", finishPointerDrag);
|
||
group.addEventListener("click", (event) => {
|
||
if (!suppressClick) return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
suppressClick = false;
|
||
}, true);
|
||
|
||
group.addEventListener("scroll", updateScrollState, { passive: true });
|
||
new MutationObserver(() => window.requestAnimationFrame(updateScrollState)).observe(
|
||
group,
|
||
{ childList: true },
|
||
);
|
||
if ("ResizeObserver" in window) new ResizeObserver(updateScrollState).observe(group);
|
||
window.addEventListener("resize", updateScrollState, { passive: true });
|
||
window.requestAnimationFrame(updateScrollState);
|
||
}
|
||
|
||
let modeActionPopoverHideTimer = null;
|
||
let modeActionPopoverAnchor = null;
|
||
|
||
function ensureModeActionPopover() {
|
||
let popover = $("#modeChipActionPopover");
|
||
if (popover) return popover;
|
||
popover = document.createElement("aside");
|
||
popover.id = "modeChipActionPopover";
|
||
popover.className = "mode-chip-action-popover";
|
||
popover.setAttribute("role", "tooltip");
|
||
popover.hidden = true;
|
||
|
||
const header = document.createElement("header");
|
||
const headingCopy = document.createElement("span");
|
||
const eyebrow = document.createElement("small");
|
||
eyebrow.textContent = "执行动作预览";
|
||
const title = document.createElement("strong");
|
||
title.dataset.modeActionTitle = "true";
|
||
headingCopy.append(eyebrow, title);
|
||
const count = document.createElement("b");
|
||
count.dataset.modeActionCount = "true";
|
||
header.append(headingCopy, count);
|
||
|
||
const list = document.createElement("ol");
|
||
list.dataset.modeActionList = "true";
|
||
popover.append(header, list);
|
||
document.body.append(popover);
|
||
return popover;
|
||
}
|
||
|
||
function renderModeActionPopover(mode) {
|
||
const popover = ensureModeActionPopover();
|
||
const actions = modeExecutionActionViews(mode);
|
||
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
|
||
$("[data-mode-action-count]", popover).textContent = actions.length
|
||
? `${actions.length} 步`
|
||
: `${Number(mode?.operation_count || 0)} 项`;
|
||
const list = $("[data-mode-action-list]", popover);
|
||
list.replaceChildren();
|
||
if (!actions.length) {
|
||
const empty = document.createElement("li");
|
||
empty.className = "mode-chip-action-empty";
|
||
empty.textContent = "执行动作明细暂未加载,请刷新后重试。";
|
||
list.append(empty);
|
||
return popover;
|
||
}
|
||
actions.forEach((action, index) => {
|
||
const item = document.createElement("li");
|
||
const step = document.createElement("span");
|
||
step.className = "mode-chip-action-step";
|
||
step.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "mode-chip-action-copy";
|
||
const actionName = document.createElement("strong");
|
||
actionName.textContent = action.action;
|
||
const metadata = document.createElement("small");
|
||
const device = document.createElement("span");
|
||
device.innerHTML = "<b>设备</b>";
|
||
device.append(document.createTextNode(action.device));
|
||
const target = document.createElement("span");
|
||
target.innerHTML = "<b>目标</b>";
|
||
target.append(document.createTextNode(action.target));
|
||
const parameters = document.createElement("span");
|
||
parameters.innerHTML = "<b>参数</b>";
|
||
parameters.append(document.createTextNode(action.parameters));
|
||
metadata.append(device, target, parameters);
|
||
copy.append(actionName, metadata);
|
||
item.append(step, copy);
|
||
list.append(item);
|
||
});
|
||
return popover;
|
||
}
|
||
|
||
function positionModeActionPopover(anchor, popover) {
|
||
const margin = 12;
|
||
const gap = 8;
|
||
const anchorRect = anchor.getBoundingClientRect();
|
||
const popoverRect = popover.getBoundingClientRect();
|
||
const maximumLeft = Math.max(margin, window.innerWidth - popoverRect.width - margin);
|
||
const left = Math.max(
|
||
margin,
|
||
Math.min(maximumLeft, anchorRect.left + (anchorRect.width - popoverRect.width) / 2),
|
||
);
|
||
const availableBelow = window.innerHeight - anchorRect.bottom - margin;
|
||
const availableAbove = anchorRect.top - margin;
|
||
const placeAbove = availableBelow < Math.min(popoverRect.height, 320)
|
||
&& availableAbove > availableBelow;
|
||
const desiredTop = placeAbove
|
||
? anchorRect.top - popoverRect.height - gap
|
||
: anchorRect.bottom + gap;
|
||
const maximumTop = Math.max(margin, window.innerHeight - popoverRect.height - margin);
|
||
popover.style.left = `${Math.max(margin, Math.min(maximumLeft, left))}px`;
|
||
popover.style.top = `${Math.max(margin, Math.min(maximumTop, desiredTop))}px`;
|
||
popover.classList.toggle("is-above", placeAbove);
|
||
}
|
||
|
||
function showModeActionPopover(button) {
|
||
const mode = modeProfiles().find((item) => item.id === button?.dataset.modeProfileId);
|
||
if (!mode) return;
|
||
window.clearTimeout(modeActionPopoverHideTimer);
|
||
modeActionPopoverHideTimer = null;
|
||
const popover = renderModeActionPopover(mode);
|
||
modeActionPopoverAnchor = button;
|
||
button.setAttribute("aria-describedby", popover.id);
|
||
popover.hidden = false;
|
||
window.requestAnimationFrame(() => {
|
||
if (!popover.hidden && modeActionPopoverAnchor === button) {
|
||
positionModeActionPopover(button, popover);
|
||
}
|
||
});
|
||
}
|
||
|
||
function hideModeActionPopover({ immediate = false } = {}) {
|
||
window.clearTimeout(modeActionPopoverHideTimer);
|
||
const hide = () => {
|
||
const popover = $("#modeChipActionPopover");
|
||
if (popover) popover.hidden = true;
|
||
modeActionPopoverAnchor = null;
|
||
modeActionPopoverHideTimer = null;
|
||
};
|
||
if (immediate) hide();
|
||
else modeActionPopoverHideTimer = window.setTimeout(hide, 90);
|
||
}
|
||
|
||
function installModeActionPreview() {
|
||
const group = $(".mode-chips");
|
||
if (!group || group.dataset.modeActionPreviewBound === "true") return;
|
||
group.dataset.modeActionPreviewBound = "true";
|
||
const resolveButton = (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
return target?.closest("button[data-mode-profile-id]");
|
||
};
|
||
group.addEventListener("pointerover", (event) => {
|
||
const button = resolveButton(event);
|
||
if (button && !button.contains(event.relatedTarget)) showModeActionPopover(button);
|
||
});
|
||
group.addEventListener("pointerout", (event) => {
|
||
const button = resolveButton(event);
|
||
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
|
||
});
|
||
group.addEventListener("focusin", (event) => {
|
||
const button = resolveButton(event);
|
||
if (button) showModeActionPopover(button);
|
||
});
|
||
group.addEventListener("focusout", (event) => {
|
||
const button = resolveButton(event);
|
||
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
|
||
});
|
||
group.addEventListener("scroll", () => hideModeActionPopover({ immediate: true }), { passive: true });
|
||
group.addEventListener("pointerdown", () => hideModeActionPopover({ immediate: true }));
|
||
group.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") hideModeActionPopover({ immediate: true });
|
||
});
|
||
const popover = ensureModeActionPopover();
|
||
popover.addEventListener("pointerenter", () => {
|
||
window.clearTimeout(modeActionPopoverHideTimer);
|
||
modeActionPopoverHideTimer = null;
|
||
});
|
||
popover.addEventListener("pointerleave", () => hideModeActionPopover());
|
||
window.addEventListener("resize", () => hideModeActionPopover({ immediate: true }), { passive: true });
|
||
}
|
||
|
||
function vueModeChipEntry(mode) {
|
||
const timeline = mode?.timeline;
|
||
const actionCount = modeExecutionActionViews(mode).length
|
||
|| Number(mode?.operation_count || 0);
|
||
const phaseSummary = Array.isArray(timeline?.phases)
|
||
? timeline.phases.map((phase) => {
|
||
if (Number(phase.duration_ms || 0) > 0) {
|
||
return `${phase.name} ${modeSecondsLabel(phase.duration_ms)}s`;
|
||
}
|
||
const timeout = Number(phase.timeout_ms || 0);
|
||
return timeout > 0
|
||
? `${phase.name} 目标到位(保护上限${modeSecondsLabel(timeout)}s)`
|
||
: `${phase.name} 目标到位`;
|
||
}).join(" → ")
|
||
: "";
|
||
let cycleLabel = "";
|
||
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
|
||
const runtimeMinutes = Number(timeline.max_runtime_ms || 0) / 60000;
|
||
const runtimeLabel = Number.isInteger(runtimeMinutes)
|
||
? String(runtimeMinutes)
|
||
: runtimeMinutes.toFixed(1);
|
||
const cycleDurationMs = Number(timeline.cycle_duration_ms || 0);
|
||
const cycle = cycleDurationMs > 0
|
||
? `${modeSecondsLabel(cycleDurationMs)}s循环`
|
||
: "目标到位循环";
|
||
cycleLabel = runtimeMinutes > 0 ? `${cycle} · 最长${runtimeLabel}min` : cycle;
|
||
}
|
||
return {
|
||
type: "mode",
|
||
key: `mode-${mode.id}`,
|
||
id: mode.id,
|
||
label: modeChipLabel(mode),
|
||
summary: modeChipSummary(mode),
|
||
icon: mode.icon || "bookmark",
|
||
command: `进入${mode.name}`,
|
||
origin: mode?.origin || "preset",
|
||
behavior: cycleLabel ? "cyclic" : "",
|
||
cycleLabel,
|
||
ariaLabel: `${mode.name || mode.id},${actionCount} 项执行动作,点击执行`,
|
||
title: [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
|
||
.filter(Boolean)
|
||
.join("\n"),
|
||
};
|
||
}
|
||
|
||
function vueModeChipEntries(modes) {
|
||
const preferredStaticOrder = [
|
||
"zero_gravity",
|
||
"office",
|
||
"nap",
|
||
"queen_passenger_driving",
|
||
"leisure_lounger",
|
||
];
|
||
const userModes = modes.filter(isUserModeProfile);
|
||
const dynamicOrder = new Map([["rocking_horse", 0], ["dynamic_cradle", 1]]);
|
||
const dynamicModes = modes
|
||
.filter((mode) => !isUserModeProfile(mode) && isDynamicModeProfile(mode))
|
||
.sort((left, right) => (
|
||
(dynamicOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER)
|
||
- (dynamicOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER)
|
||
));
|
||
const staticModes = modes.filter(
|
||
(mode) => !isUserModeProfile(mode) && !isDynamicModeProfile(mode),
|
||
);
|
||
const preferredStaticModes = preferredStaticOrder
|
||
.map((modeId) => staticModes.find((mode) => mode.id === modeId))
|
||
.filter(Boolean);
|
||
const otherStaticModes = staticModes.filter((mode) => !preferredStaticModes.includes(mode));
|
||
const entries = [];
|
||
const appendLabel = (label, id) => entries.push({
|
||
type: "label",
|
||
key: `label-${id}`,
|
||
id,
|
||
label,
|
||
});
|
||
const appendDivider = (id) => entries.push({
|
||
type: "divider",
|
||
key: `divider-${id}`,
|
||
id,
|
||
});
|
||
if (userModes.length) {
|
||
appendLabel("自定义模式", "custom");
|
||
entries.push(...userModes.map(vueModeChipEntry));
|
||
}
|
||
if (userModes.length && (staticModes.length || dynamicModes.length)) appendDivider("custom");
|
||
if (staticModes.length) {
|
||
appendLabel("静态模式", "static");
|
||
entries.push(...preferredStaticModes.map(vueModeChipEntry));
|
||
entries.push(...otherStaticModes.map(vueModeChipEntry));
|
||
}
|
||
if (dynamicModes.length) {
|
||
if (userModes.length || staticModes.length) appendDivider("dynamic");
|
||
appendLabel("动态模式", "dynamic");
|
||
entries.push(...dynamicModes.map(vueModeChipEntry));
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function populateModeChips() {
|
||
const group = $(".mode-chips");
|
||
if (!group) return;
|
||
const buttons = () => $$("button", group);
|
||
const modes = modeProfiles();
|
||
if (window.CognitiveAgentVueUI?.renderModeChips?.(group, vueModeChipEntries(modes))) {
|
||
populateModeGallery();
|
||
return;
|
||
}
|
||
const runtimeModeIds = new Set(modes.map((mode) => mode.id));
|
||
$$('button[data-mode-origin="user"]', group).forEach((button) => {
|
||
if (!runtimeModeIds.has(button.dataset.modeProfileId)) button.remove();
|
||
});
|
||
modes.forEach((mode) => {
|
||
const label = modeChipLabel(mode);
|
||
let button = buttons().find((item) => item.dataset.modeProfileId === mode.id)
|
||
|| buttons().find((item) => item.textContent.trim() === label);
|
||
if (!button) {
|
||
button = document.createElement("button");
|
||
button.type = "button";
|
||
const icon = document.createElement("i");
|
||
icon.dataset.lucide = mode.icon || "bookmark";
|
||
icon.className = "mode-chip-icon";
|
||
const labelNode = document.createElement("span");
|
||
labelNode.className = "mode-chip-label";
|
||
labelNode.textContent = label;
|
||
button.append(icon, labelNode);
|
||
group.append(button);
|
||
}
|
||
const icon = $("i[data-lucide]", button);
|
||
if (icon) {
|
||
icon.classList.add("mode-chip-icon");
|
||
icon.dataset.lucide = mode.icon || icon.dataset.lucide || "bookmark";
|
||
}
|
||
let labelNode = $(".mode-chip-label", button) || $("span", button);
|
||
if (!labelNode) {
|
||
labelNode = document.createElement("span");
|
||
button.append(labelNode);
|
||
}
|
||
labelNode.classList.add("mode-chip-label");
|
||
labelNode.textContent = label;
|
||
button.dataset.modeProfileId = mode.id;
|
||
button.dataset.modeCommand = `进入${mode.name}`;
|
||
button.dataset.modeOrigin = mode?.origin || "preset";
|
||
button.setAttribute("aria-pressed", "false");
|
||
const actionCount = modeExecutionActionViews(mode).length
|
||
|| Number(mode.operation_count || 0);
|
||
button.setAttribute(
|
||
"aria-label",
|
||
`${mode.name || mode.id},${actionCount} 项执行动作,点击执行`,
|
||
);
|
||
let summary = $(".mode-chip-summary", button);
|
||
if (!summary) {
|
||
summary = document.createElement("small");
|
||
summary.className = "mode-chip-summary";
|
||
button.append(summary);
|
||
}
|
||
summary.textContent = modeChipSummary(mode);
|
||
let runIcon = $(".mode-chip-run", button);
|
||
if (!runIcon) {
|
||
runIcon = document.createElement("i");
|
||
runIcon.className = "mode-chip-run";
|
||
runIcon.dataset.lucide = "play";
|
||
button.append(runIcon);
|
||
}
|
||
runIcon.dataset.modeRun = "true";
|
||
runIcon.title = `执行${mode.name || mode.id}`;
|
||
const timeline = mode.timeline;
|
||
let cycleBadge = $(".mode-cycle-badge", button);
|
||
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
|
||
if (!cycleBadge) {
|
||
cycleBadge = document.createElement("small");
|
||
cycleBadge.className = "mode-cycle-badge";
|
||
button.append(cycleBadge);
|
||
}
|
||
const runtimeMinutes = Number(timeline.max_runtime_ms || 0) / 60000;
|
||
const runtimeLabel = Number.isInteger(runtimeMinutes)
|
||
? String(runtimeMinutes)
|
||
: runtimeMinutes.toFixed(1);
|
||
const cycleDurationMs = Number(timeline.cycle_duration_ms || 0);
|
||
const cycleLabel = cycleDurationMs > 0
|
||
? `${modeSecondsLabel(cycleDurationMs)}s循环`
|
||
: "目标到位循环";
|
||
cycleBadge.textContent = runtimeMinutes > 0
|
||
? `${cycleLabel} · 最长${runtimeLabel}min`
|
||
: cycleLabel;
|
||
button.dataset.modeBehavior = "cyclic";
|
||
} else if (cycleBadge) {
|
||
cycleBadge.remove();
|
||
}
|
||
const phaseSummary = Array.isArray(timeline?.phases)
|
||
? timeline.phases.map((phase) => {
|
||
if (Number(phase.duration_ms || 0) > 0) {
|
||
return `${phase.name} ${modeSecondsLabel(phase.duration_ms)}s`;
|
||
}
|
||
const timeout = Number(phase.timeout_ms || 0);
|
||
return timeout > 0
|
||
? `${phase.name} 目标到位(保护上限${modeSecondsLabel(timeout)}s)`
|
||
: `${phase.name} 目标到位`;
|
||
}).join(" → ")
|
||
: "";
|
||
button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
});
|
||
|
||
const preferredStaticOrder = [
|
||
"zero_gravity",
|
||
"office",
|
||
"nap",
|
||
"queen_passenger_driving",
|
||
"leisure_lounger",
|
||
];
|
||
const allButtons = buttons();
|
||
const profilesById = new Map(modes.map((mode) => [String(mode.id), mode]));
|
||
const userButtons = allButtons.filter((button) => {
|
||
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
|
||
return mode ? isUserModeProfile(mode) : button.dataset.modeOrigin === "user";
|
||
});
|
||
const dynamicOrder = new Map([["rocking_horse", 0], ["dynamic_cradle", 1]]);
|
||
const dynamicButtons = allButtons.filter((button) => {
|
||
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
|
||
return mode && !isUserModeProfile(mode) && isDynamicModeProfile(mode);
|
||
}).sort((left, right) => (
|
||
(dynamicOrder.get(left.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
|
||
- (dynamicOrder.get(right.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
|
||
));
|
||
const staticButtons = allButtons.filter(
|
||
(button) => !userButtons.includes(button) && !dynamicButtons.includes(button),
|
||
);
|
||
const preferredStaticButtons = preferredStaticOrder
|
||
.map((modeId) => staticButtons.find((button) => button.dataset.modeProfileId === modeId))
|
||
.filter(Boolean);
|
||
const otherStaticButtons = staticButtons.filter(
|
||
(button) => !preferredStaticButtons.includes(button),
|
||
);
|
||
$$(".mode-group-divider, .mode-group-label", group).forEach((divider) => divider.remove());
|
||
const appendGroupLabel = (label, id) => {
|
||
const groupLabel = document.createElement("span");
|
||
groupLabel.className = "mode-group-label";
|
||
groupLabel.dataset.modeGroupLabel = id;
|
||
groupLabel.textContent = label;
|
||
group.append(groupLabel);
|
||
};
|
||
const appendDivider = (id) => {
|
||
const divider = document.createElement("span");
|
||
divider.className = "mode-group-divider";
|
||
divider.dataset.modeDivider = id;
|
||
divider.setAttribute("aria-hidden", "true");
|
||
group.append(divider);
|
||
};
|
||
if (userButtons.length) {
|
||
appendGroupLabel("自定义模式", "custom");
|
||
userButtons.forEach((button) => group.append(button));
|
||
}
|
||
if (userButtons.length && (staticButtons.length || dynamicButtons.length)) {
|
||
appendDivider("custom");
|
||
}
|
||
if (staticButtons.length) appendGroupLabel("静态模式", "static");
|
||
preferredStaticButtons.forEach((button) => group.append(button));
|
||
otherStaticButtons.forEach((button) => group.append(button));
|
||
if (dynamicButtons.length) {
|
||
if (userButtons.length || staticButtons.length) appendDivider("dynamic");
|
||
appendGroupLabel("动态模式", "dynamic");
|
||
}
|
||
dynamicButtons.forEach((button) => group.append(button));
|
||
populateModeGallery();
|
||
refreshIcons();
|
||
}
|
||
|
||
async function deleteUserModeProfile(modeProfileId) {
|
||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||
if (!mode || !isUserModeProfile(mode)) return;
|
||
const presetOverride = mode.preset_override === true;
|
||
const confirmation = presetOverride
|
||
? `恢复“${mode.name}”的初始预设配置?当前保存的名称、自然语言和控制指令流将被删除。`
|
||
: `删除自定义功能模式“${mode.name}”?删除后无法恢复。`;
|
||
if (!window.confirm(confirmation)) return;
|
||
|
||
const deleteButtons = $$(`[data-mode-delete="${CSS.escape(modeProfileId)}"]`);
|
||
deleteButtons.forEach((button) => {
|
||
button.disabled = true;
|
||
button.setAttribute("aria-busy", "true");
|
||
});
|
||
try {
|
||
const query = new URLSearchParams({
|
||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id || "",
|
||
});
|
||
const response = await apiRequest(
|
||
`/api/mode-profiles/${encodeURIComponent(modeProfileId)}?${query}`,
|
||
{ method: "DELETE" },
|
||
);
|
||
const payload = await response.json();
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
|
||
await loadRuntime();
|
||
showToast(presetOverride ? `${mode.name}已恢复初始预设` : `${mode.name}已删除`);
|
||
} catch (error) {
|
||
showToast(`删除失败:${error.message || "服务不可用"}`);
|
||
deleteButtons.forEach((button) => {
|
||
button.disabled = false;
|
||
button.removeAttribute("aria-busy");
|
||
});
|
||
}
|
||
}
|
||
|
||
function setModeExecutionState(modeProfileId, modeState = "idle") {
|
||
window.clearTimeout(state.modeRunTimer);
|
||
state.modeRunTimer = null;
|
||
$$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
|
||
const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
|
||
button.classList.toggle("running", active && modeState === "running");
|
||
button.classList.toggle("succeeded", active && modeState === "succeeded");
|
||
button.classList.toggle("failed", active && modeState === "failed");
|
||
button.dataset.modeState = active ? modeState : "idle";
|
||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||
button.setAttribute("aria-busy", active && modeState === "running" ? "true" : "false");
|
||
const stateLabel = $(".mode-gallery-state", button);
|
||
if (stateLabel) {
|
||
stateLabel.textContent = active && modeState === "running"
|
||
? "执行中"
|
||
: active && modeState === "succeeded"
|
||
? "已完成"
|
||
: active && modeState === "failed"
|
||
? "执行失败"
|
||
: "点击执行";
|
||
}
|
||
});
|
||
if (modeProfileId && ["succeeded", "failed"].includes(modeState)) {
|
||
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
|
||
}
|
||
}
|
||
|
||
function setModeChipsDisabled(disabled) {
|
||
$$(".mode-chips button[data-mode-command], #modeGallery button[data-mode-command]").forEach((button) => {
|
||
button.disabled = disabled;
|
||
});
|
||
}
|
||
|
||
async function executeSavedModeProfile(mode, { command = "" } = {}) {
|
||
if (!mode?.id || !mode?.command_flow?.source_request_id) return false;
|
||
const modeCommand = String(command || `进入${mode.name || mode.id}`).trim();
|
||
const assistant = window.CognitiveAgentAssistant;
|
||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||
if (state.executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
&& !isLocalDecisionHost()
|
||
&& state.hardwareAccess?.state !== "approved") {
|
||
showToast(state.hardwareAccess?.state === "pending"
|
||
? "申请仍在等待本地审批"
|
||
: "请先申请并获得本地真实台架使用权");
|
||
return true;
|
||
}
|
||
|
||
closeModeGallery({ restoreFocus: false });
|
||
const runButton = $("#runButton");
|
||
const input = $("#utterance");
|
||
runButton.disabled = true;
|
||
setModeChipsDisabled(true);
|
||
setModeExecutionState(mode.id, "running");
|
||
assistant?.open?.();
|
||
input.value = modeCommand;
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
setRunStatus("processing", "MODE COMMAND FLOW");
|
||
$("#resultTitle").textContent = `正在直接执行${mode.name}`;
|
||
try {
|
||
const payload = await postControlPhase(
|
||
`/api/mode-profiles/${encodeURIComponent(mode.id)}/execute`,
|
||
{
|
||
client_type: "web_console_mode_card",
|
||
conversation_id: state.conversationId,
|
||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||
execution_mode: state.executionMode,
|
||
control_transport: state.controlTransport,
|
||
...(state.executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
? { hardware_client_id: state.hardwareClientId }
|
||
: {}),
|
||
...(state.executionMode === "simulated_hardware"
|
||
? { simulation_fault: $("#simulationFault").value }
|
||
: {}),
|
||
},
|
||
state.executionMode,
|
||
);
|
||
if (!payload) throw new Error("模式卡片未返回执行结果");
|
||
renderResult(payload);
|
||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||
$("#resultTitle").textContent = completed
|
||
? `${mode.name}执行完成`
|
||
: `${mode.name}执行未完成`;
|
||
setModeExecutionState(mode.id, completed ? "succeeded" : "failed");
|
||
assistant?.announceModeExecution?.(mode, payload, { command: modeCommand });
|
||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||
showToast(completed
|
||
? `${mode.name}已直接执行保存的控制指令流`
|
||
: `${mode.name}未完成,请查看执行结果`);
|
||
} catch (error) {
|
||
setModeExecutionState(mode.id, "failed");
|
||
setRunStatus("failed", "MODE FAILED");
|
||
$("#resultTitle").textContent = `${mode.name}执行失败`;
|
||
assistant?.announceModeExecution?.(mode, null, { command: modeCommand, error });
|
||
showToast(error.message || "模式执行失败");
|
||
} finally {
|
||
if (input.value === modeCommand) {
|
||
input.value = "";
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
}
|
||
runButton.disabled = false;
|
||
setModeChipsDisabled(false);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyModeCommand(command, modeProfileId = null) {
|
||
const modeCommand = String(command || "").trim();
|
||
if (!modeCommand) return false;
|
||
const assistant = window.CognitiveAgentAssistant;
|
||
if (assistant?.isPending?.()) {
|
||
showToast("AI助手正在处理上一条指令,请稍候");
|
||
return false;
|
||
}
|
||
if ($("#runButton")?.disabled) {
|
||
showToast("上一条指令仍在执行,请稍候");
|
||
return false;
|
||
}
|
||
const mode = modeProfiles().find((item) => item.id === modeProfileId);
|
||
setModeRailSelection(mode);
|
||
closeModeGallery({ restoreFocus: false });
|
||
assistant?.open?.();
|
||
if (mode?.command_flow?.source_request_id) {
|
||
void executeSavedModeProfile(mode, { command: modeCommand });
|
||
return true;
|
||
}
|
||
const input = $("#utterance");
|
||
const form = $("#requestForm");
|
||
if (!input || !form) {
|
||
showToast("AI助手暂时不可用,请稍后重试");
|
||
return false;
|
||
}
|
||
input.value = modeCommand;
|
||
delete input.dataset.speechStage;
|
||
delete input.dataset.speechUtteranceId;
|
||
delete input.dataset.speechValue;
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
state.pendingModeProfileId = modeProfileId;
|
||
if (modeProfileId) setModeExecutionState(modeProfileId, "running");
|
||
form.requestSubmit();
|
||
return true;
|
||
}
|
||
|
||
function applyRuntimeTargetPolicy(runtime) {
|
||
const selectedTransport = selectedVehicleTransport();
|
||
const wirelessDualChannel = state.controlTransport === "wireless_mqtt"
|
||
&& selectedTransport?.target_policy === "dual_channel_mqtt_can0_passenger_can1_vehicle";
|
||
const passengerReady = wirelessDualChannel
|
||
? selectedTransport?.configured === true
|
||
: runtime?.hardware?.configured === true;
|
||
const driverReady = selectedTransport?.configured === true;
|
||
const driverOnly = driverReady && !passengerReady;
|
||
const stage = $("#seatTwin");
|
||
if (stage) {
|
||
stage.dataset.passengerChannel = "CH0";
|
||
stage.dataset.driverChannel = state.controlTransport === "wireless_mqtt" ? "MQTT" : "CH1";
|
||
stage.dataset.passengerConfigured = String(passengerReady);
|
||
stage.dataset.driverConfigured = String(driverReady);
|
||
}
|
||
['passenger', 'driver'].forEach((seat) => {
|
||
const ready = seat === 'passenger' ? passengerReady : driverReady;
|
||
const card = $(`.seat-card[data-seat="${seat}"]`);
|
||
if (card) card.classList.toggle('hardware-unavailable', !ready);
|
||
});
|
||
const jointCopy = $(".joint-twin-heading p");
|
||
if (jointCopy) jointCopy.textContent = passengerReady && driverReady
|
||
? "CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈,并与车身设备联合展示。"
|
||
: "前排双座椅保持独立展示;未配置的物理通道会明确标记为不可用。";
|
||
if (driverOnly) setTwinSelection("driver");
|
||
const input = $("#utterance");
|
||
if (input && driverOnly) {
|
||
input.placeholder = "例如:打开主驾通风,并把空调温度调到24度";
|
||
}
|
||
window.CognitiveAgentGesture?.setEnabled?.(!driverOnly, {
|
||
reason: driverOnly ? "CH1 主驾实车测试不启用无闭环的座椅位置手势" : "",
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
async function loadRuntime() {
|
||
try {
|
||
const response = await apiRequest("/api/runtime", { cache: "no-store" });
|
||
if (!response.ok) throw await apiResponseError(response);
|
||
state.runtime = await response.json();
|
||
renderControlTransport();
|
||
applyRuntimeTargetPolicy(state.runtime);
|
||
populateVehicleSelector();
|
||
populateModeChips();
|
||
const configuredEffort = state.runtime.openai_reasoning_effort_default || "medium";
|
||
if (!availableReasoningEfforts().includes(state.reasoningEffort)) {
|
||
state.reasoningEffort = configuredEffort;
|
||
}
|
||
const hardware = state.runtime.hardware || {};
|
||
const vehicleCan = selectedVehicleTransport() || {};
|
||
const mqttSelected = state.controlTransport === "wireless_mqtt";
|
||
const mqttDualChannel = mqttSelected
|
||
&& vehicleCan.target_policy === "dual_channel_mqtt_can0_passenger_can1_vehicle";
|
||
const seatHardwareReady = Boolean(
|
||
mqttDualChannel ? vehicleCan.configured : hardware.configured,
|
||
);
|
||
const vehicleHardwareReady = Boolean(vehicleCan.configured);
|
||
const linkedHardwareReady = seatHardwareReady && vehicleHardwareReady;
|
||
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
|
||
const hardwareExecutionReady = Boolean(hardware.configured || vehicleCan.configured);
|
||
const gptButton = $('[data-provider="openai"]');
|
||
if (state.runtime.openai_available) {
|
||
const responsesEndpoint = state.runtime.openai_responses_endpoint || "Responses API";
|
||
$("#providerNote").textContent = `${state.runtime.openai_model} · ${responsesEndpoint} · 失败时自动降级本地规则`;
|
||
selectProvider("openai");
|
||
} else {
|
||
gptButton.disabled = true;
|
||
gptButton.title = "设置 OPENAI_API_KEY 后启用";
|
||
$("#providerNote").textContent = "未设置 OPENAI_API_KEY,已切换为本地规则解析";
|
||
selectProvider("local");
|
||
}
|
||
syncReasoningControls();
|
||
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
|
||
const hardwareButton = $("#hardwareMode");
|
||
// “真实下发”是操作员手动选择的执行目标。硬件暂不可用时仍允许
|
||
// 选择该目标并展示诊断信息;真正的控制提交继续由后端安全门拦截。
|
||
hardwareButton.disabled = false;
|
||
hardwareButton.dataset.executionState = hardwareExecutionReady ? "available" : "blocked";
|
||
hardwareButton.title = hardwareExecutionReady
|
||
? "真实下发已通过后端执行配置校验"
|
||
: `可手动选择真实下发;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
|
||
const simulation = state.runtime.simulated_hardware || {};
|
||
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
|
||
? " · 动作防冲突"
|
||
: "";
|
||
const accessText = !mqttDualChannel && !ch1DriverOnly
|
||
&& hardware.access_policy === "local_approval_idle_lease"
|
||
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
|
||
: "";
|
||
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
|
||
? `${mqttSelected ? "MQTT" : "CH1"} 主驾 + 车端设备 · CAN 字段回读${accessText}${concurrencyText}`
|
||
: linkedHardwareReady
|
||
? `CH0 座椅 + ${mqttSelected ? "MQTT" : `CH${vehicleCan.channel}`} 车身${accessText}${concurrencyText}`
|
||
: seatHardwareReady
|
||
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
|
||
: "已阻断";
|
||
$("#hardwareCoverage").className = hardwareExecutionReady ? "available" : "blocked";
|
||
const preferredMode = state.preferredExecutionMode || DEFAULT_EXECUTION_MODE;
|
||
const modeAvailable = preferredMode === "simulated_hardware"
|
||
? simulation.enabled
|
||
: true;
|
||
selectExecutionMode(modeAvailable ? preferredMode : "dry_run", { remember: false });
|
||
refreshIcons();
|
||
} catch (error) {
|
||
$("#providerNote").textContent = "无法读取后端运行状态";
|
||
showToast(`运行时连接失败:${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function synchronizePassengerSeatState() {
|
||
const requestedTransport = state.controlTransport;
|
||
const mqttSelected = requestedTransport === "wireless_mqtt";
|
||
const stateReady = mqttSelected
|
||
? selectedVehicleTransport()?.state_configured
|
||
: state.runtime?.hardware?.configured;
|
||
if (!stateReady) {
|
||
updateWiredCanProbe("passenger", false, requestedTransport);
|
||
setFeedbackSignal("passenger", false, requestedTransport);
|
||
return false;
|
||
}
|
||
const query = new URLSearchParams({
|
||
conversation_id: state.conversationId,
|
||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||
control_transport: requestedTransport,
|
||
hardware_client_id: state.hardwareClientId,
|
||
});
|
||
try {
|
||
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
|
||
cache: "no-store",
|
||
});
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return false;
|
||
}
|
||
if (!response.ok) throw await apiResponseError(response);
|
||
const payload = await response.json();
|
||
if (requestedTransport !== state.controlTransport) return false;
|
||
updateWiredCanProbe("passenger", true, requestedTransport);
|
||
setFeedbackSignal("passenger", true, requestedTransport);
|
||
renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
|
||
state.seatFeedbackSources.passenger = payload.complete
|
||
? payload.source === "wireless_mqtt_ch0" ? "wireless_mqtt_ch0" : "physical_can"
|
||
: "physical_partial";
|
||
state.seatFeedbackSource = state.seatFeedbackSources.passenger;
|
||
updateSeatFeedbackModeLabel();
|
||
return true;
|
||
} catch (_error) {
|
||
updateWiredCanProbe("passenger", false, requestedTransport);
|
||
setFeedbackSignal("passenger", false, requestedTransport);
|
||
if (!["physical_can", "wireless_mqtt_ch0"].includes(state.seatFeedbackSources.passenger)) {
|
||
state.seatFeedbackSources.passenger = "unavailable";
|
||
updateSeatFeedbackModeLabel();
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function synchronizeDriverSeatState() {
|
||
const requestedTransport = state.controlTransport;
|
||
if (!selectedVehicleTransport()?.state_configured) {
|
||
updateWiredCanProbe("vehicle", false, requestedTransport);
|
||
setFeedbackSignal("vehicle", false, requestedTransport);
|
||
return false;
|
||
}
|
||
const query = new URLSearchParams({
|
||
conversation_id: state.conversationId,
|
||
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
|
||
control_transport: requestedTransport,
|
||
hardware_client_id: state.hardwareClientId,
|
||
});
|
||
try {
|
||
const response = await apiRequest(`/api/hardware/vehicle-state?${query}`, { cache: "no-store" });
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return false;
|
||
}
|
||
if (!response.ok) throw await apiResponseError(response);
|
||
const payload = await response.json();
|
||
if (requestedTransport !== state.controlTransport) return false;
|
||
updateWiredCanProbe("vehicle", true, requestedTransport);
|
||
setFeedbackSignal(
|
||
"vehicle",
|
||
Array.isArray(payload.observed_frame_ids) && payload.observed_frame_ids.length > 0,
|
||
requestedTransport,
|
||
);
|
||
applyAuthoritativeVehicleState(payload);
|
||
state.seatFeedbackSources.driver = payload.complete
|
||
? payload.source === "wireless_mqtt_ch1" ? "wireless_mqtt_ch1" : "physical_can_ch1"
|
||
: "physical_partial";
|
||
state.seatFeedbackSource = state.seatFeedbackSources.driver;
|
||
updateSeatFeedbackModeLabel();
|
||
return true;
|
||
} catch (error) {
|
||
updateWiredCanProbe("vehicle", false, requestedTransport);
|
||
setFeedbackSignal("vehicle", false, requestedTransport);
|
||
state.vehicleStateSnapshot = {
|
||
...(state.vehicleStateSnapshot || {}),
|
||
error: error.message || String(error),
|
||
errorStatus: Number(error.status) || null,
|
||
errorCode: error.code || "",
|
||
errorReason: error.serverMessage || error.message || String(error),
|
||
};
|
||
renderCabinLiveStatus();
|
||
if (!["physical_can_ch1", "wireless_mqtt_ch1"].includes(state.seatFeedbackSources.driver)) {
|
||
state.seatFeedbackSources.driver = "unavailable";
|
||
updateSeatFeedbackModeLabel();
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function synchronizePhysicalSeatState() {
|
||
if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
|
||
state.physicalStateSyncPromise = (async () => {
|
||
if (state.controlTransport === "wireless_mqtt") {
|
||
// CAN0 may legitimately be absent and wait for its bounded seat-status
|
||
// timeout. Keep that wait single-flight in the background so cached
|
||
// CAN1 vehicle state can still refresh at the MQTT polling cadence.
|
||
if (!state.passengerStateSyncPromise) {
|
||
let passengerPromise;
|
||
passengerPromise = synchronizePassengerSeatState().finally(() => {
|
||
if (state.passengerStateSyncPromise === passengerPromise) {
|
||
state.passengerStateSyncPromise = null;
|
||
}
|
||
});
|
||
state.passengerStateSyncPromise = passengerPromise;
|
||
}
|
||
return synchronizeDriverSeatState();
|
||
}
|
||
// The wired channels share one adapter owner; keep passive reads serialized.
|
||
const passenger = await synchronizePassengerSeatState();
|
||
const driver = await synchronizeDriverSeatState();
|
||
return passenger || driver;
|
||
})();
|
||
try {
|
||
return await state.physicalStateSyncPromise;
|
||
} finally {
|
||
state.physicalStateSyncPromise = null;
|
||
}
|
||
}
|
||
|
||
function restartPhysicalStatePolling() {
|
||
if (state.physicalStatePoller) window.clearInterval(state.physicalStatePoller);
|
||
state.physicalStatePoller = window.setInterval(() => {
|
||
const activeModule = document.body.dataset.module;
|
||
if (document.visibilityState !== "visible") return;
|
||
if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
|
||
synchronizePhysicalSeatState();
|
||
}, physicalStatePollIntervalMs());
|
||
}
|
||
|
||
async function loadAuthStatus() {
|
||
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
|
||
if (!response.ok) return;
|
||
const auth = await response.json();
|
||
if (auth.enabled && !auth.authenticated) {
|
||
window.location.replace("/login");
|
||
return;
|
||
}
|
||
const logoutButton = $("#v2LogoutButton");
|
||
if (logoutButton) logoutButton.hidden = !auth.enabled;
|
||
}
|
||
|
||
async function logout() {
|
||
await apiRequest("/api/auth/logout", { method: "POST" });
|
||
window.location.replace("/login");
|
||
}
|
||
|
||
function setRunStatus(status, text) {
|
||
const element = $("#runStatus");
|
||
element.className = `run-status ${status}`;
|
||
element.textContent = text;
|
||
}
|
||
|
||
function yamlScalar(value) {
|
||
if (value === null) return "null";
|
||
if (typeof value === "boolean" || typeof value === "number") return String(value);
|
||
return JSON.stringify(String(value));
|
||
}
|
||
|
||
function yamlLines(value, indent = 0) {
|
||
const pad = " ".repeat(indent);
|
||
if (Array.isArray(value)) {
|
||
if (!value.length) return [`${pad}[]`];
|
||
return value.flatMap((item) => {
|
||
if (item !== null && typeof item === "object") {
|
||
const lines = yamlLines(item, indent + 2);
|
||
return [`${pad}-${lines[0].slice(indent + 1)}`, ...lines.slice(1)];
|
||
}
|
||
return [`${pad}- ${yamlScalar(item)}`];
|
||
});
|
||
}
|
||
if (value !== null && typeof value === "object") {
|
||
const entries = Object.entries(value);
|
||
if (!entries.length) return [`${pad}{}`];
|
||
return entries.flatMap(([key, item]) => {
|
||
if (item !== null && typeof item === "object") {
|
||
return [`${pad}${key}:`, ...yamlLines(item, indent + 2)];
|
||
}
|
||
return [`${pad}${key}: ${yamlScalar(item)}`];
|
||
});
|
||
}
|
||
return [`${pad}${yamlScalar(value)}`];
|
||
}
|
||
|
||
function toYaml(value) {
|
||
return value ? `${yamlLines(value).join("\n")}\n` : "# 尚未生成\n";
|
||
}
|
||
|
||
function renderDecision(result) {
|
||
const list = $("#decisionList");
|
||
list.replaceChildren();
|
||
result.decision_summary.forEach((summary) => {
|
||
const item = document.createElement("li");
|
||
item.textContent = summary;
|
||
list.append(item);
|
||
});
|
||
$("#providerUsed").textContent = result.provider_used || "--";
|
||
$("#tracePanelState").textContent = result.provider_used || result.provider_requested || "READY";
|
||
}
|
||
|
||
function renderTrace(events) {
|
||
const list = $("#traceList");
|
||
list.replaceChildren();
|
||
let duration = 0;
|
||
events.forEach((event) => {
|
||
duration += event.duration_ms || 0;
|
||
const row = document.createElement("div");
|
||
row.className = `trace-event ${event.status}`;
|
||
const code = document.createElement("code");
|
||
code.textContent = event.stage;
|
||
const summary = document.createElement("span");
|
||
summary.textContent = event.summary;
|
||
const time = document.createElement("small");
|
||
time.textContent = `${event.duration_ms || 0} ms`;
|
||
row.append(code, summary, time);
|
||
list.append(row);
|
||
});
|
||
$("#traceDuration").textContent = `${duration} ms`;
|
||
$("#traceNavMeta").textContent = `${events.length} 个阶段 · ${duration} ms`;
|
||
}
|
||
|
||
function renderValidation(issues) {
|
||
const list = $("#validationList");
|
||
list.replaceChildren();
|
||
$("#validationCount").textContent = `${issues.length} 条`;
|
||
if (!issues.length) {
|
||
const row = document.createElement("div");
|
||
row.className = "validation-item";
|
||
row.innerHTML = "<b>PASS</b><code>all</code><span>公共 Schema、领域 Schema 和语义约束均通过。</span>";
|
||
list.append(row);
|
||
return;
|
||
}
|
||
issues.forEach((issue) => {
|
||
const row = document.createElement("div");
|
||
row.className = `validation-item ${issue.severity}`;
|
||
const severity = document.createElement("b");
|
||
severity.textContent = issue.severity.toUpperCase();
|
||
const code = document.createElement("code");
|
||
code.textContent = `${issue.code} · ${issue.path}`;
|
||
const message = document.createElement("span");
|
||
message.textContent = issue.message;
|
||
row.append(severity, code, message);
|
||
list.append(row);
|
||
});
|
||
}
|
||
|
||
function resetSeatState() {
|
||
$$('.seat-card[data-seat]').forEach((card) => {
|
||
card.dataset.ventilationState = "unknown";
|
||
card.dataset.ventilationLevel = "0";
|
||
card.dataset.heatingState = "unknown";
|
||
card.dataset.heatingLevel = "0";
|
||
card.dataset.massageState = "unknown";
|
||
});
|
||
$$(".thermal-states > span").forEach((item) => {
|
||
item.classList.remove("on");
|
||
$("b", item).textContent = "--";
|
||
});
|
||
$$(".motion-axis").forEach((item) => {
|
||
$("b", item).textContent = "--";
|
||
$("i", item).style.width = "0%";
|
||
item.classList.remove("fault");
|
||
});
|
||
}
|
||
|
||
function resetVehicleStatusDisplay() {
|
||
state.seatActuatorState = {};
|
||
resetSeatState();
|
||
const setText = (selector, value) => {
|
||
const element = $(selector);
|
||
if (element) element.textContent = value;
|
||
};
|
||
$$('[data-domain-panel]').forEach((panel) => panel.classList.remove("active"));
|
||
|
||
setText("#climatePower", "UNKNOWN");
|
||
setText("#driverTemperature", "-- ℃");
|
||
setText("#passengerTemperature", "-- ℃");
|
||
setText("#driverAirDirection", "-");
|
||
setText("#passengerAirDirection", "-");
|
||
const airflow = $("#airflowVisual");
|
||
if (airflow) airflow.dataset.level = "0";
|
||
setText("#fanLevel", "未知");
|
||
setText("#cycleMode", "未知");
|
||
setText("#acState", "未知");
|
||
setText("#defrostState", "未知");
|
||
|
||
setText("#vehicleTelemetryState", "只读");
|
||
setText("#vehicleGear", "--");
|
||
setText("#vehicleSpeed", "-- km/h");
|
||
[
|
||
"frontLeftDoorState",
|
||
"frontRightDoorState",
|
||
"rearLeftDoorState",
|
||
"rearRightDoorState",
|
||
].forEach((id) => setText(`#${id}`, "未知"));
|
||
$$('[data-door-state]').forEach((row) => { row.dataset.state = "unknown"; });
|
||
|
||
setText("#roofState", "UNKNOWN");
|
||
setText("#shadePositionValue", "未知");
|
||
setText("#shadeMotionState", "未知");
|
||
setText("#shadeInitializedState", "未知");
|
||
setText("#shadeThermalState", "未知");
|
||
const shadeMeter = $("#shadePositionMeter");
|
||
if (shadeMeter) shadeMeter.style.removeProperty("width");
|
||
|
||
setText("#ambientPower", "UNKNOWN");
|
||
setText("#ambientMode", "未知");
|
||
setText("#interiorLightPower", "未知");
|
||
setText("#ambientBrightness", "- / -");
|
||
setText("#ambientColorIndex", "- / -");
|
||
const ambientPanel = $('[data-domain-panel="ambient_light"]');
|
||
if (ambientPanel) {
|
||
ambientPanel.dataset.ambientPower = "unknown";
|
||
ambientPanel.dataset.interiorPower = "unknown";
|
||
}
|
||
|
||
setText("#windowState", "UNKNOWN");
|
||
["frontLeftWindow", "frontRightWindow", "rearLeftWindow", "rearRightWindow"]
|
||
.forEach((id) => setText(`#${id}`, "未知"));
|
||
$$('[data-window-meter]').forEach((meter) => {
|
||
meter.dataset.state = "unknown";
|
||
meter.style.removeProperty("--window-open");
|
||
});
|
||
|
||
setText("#frontLightState", "UNKNOWN");
|
||
["lowBeamState", "highBeamState", "fogLightState", "turnSignalState"]
|
||
.forEach((id) => setText(`#${id}`, "未知"));
|
||
$$('[data-light-meter]').forEach((meter) => { meter.dataset.state = "unknown"; });
|
||
|
||
setText("#cabinSummaryGear", "--");
|
||
setText("#cabinSummarySpeed", "-- km/h");
|
||
setText("#cabinSummaryDoors", "状态未知");
|
||
const summarySystem = $("#cabinSummarySystem");
|
||
if (summarySystem) {
|
||
summarySystem.textContent = "等待状态";
|
||
summarySystem.dataset.state = "waiting";
|
||
}
|
||
}
|
||
|
||
function mergeSeatActuatorState(previousState, patchState) {
|
||
const mergedState = { ...(previousState || {}) };
|
||
Object.entries(patchState || {}).forEach(([seat, functions]) => {
|
||
const previousSeat = mergedState[seat] || {};
|
||
const mergedSeat = { ...previousSeat };
|
||
Object.entries(functions || {}).forEach(([feature, value]) => {
|
||
if (feature === "position") {
|
||
const previousPosition = previousSeat.position || {};
|
||
const mergedPosition = { ...previousPosition };
|
||
Object.entries(value || {}).forEach(([axis, motion]) => {
|
||
mergedPosition[axis] = motion && typeof motion === "object"
|
||
? { ...(previousPosition[axis] || {}), ...motion }
|
||
: motion;
|
||
});
|
||
mergedSeat.position = mergedPosition;
|
||
return;
|
||
}
|
||
if (feature === "dynamic_mode") {
|
||
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
|
||
if (!item) return;
|
||
const running = value.state === "running";
|
||
item.classList.toggle("running", running);
|
||
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
|
||
const runtimeLabel = Number.isInteger(runtimeMinutes)
|
||
? String(runtimeMinutes)
|
||
: runtimeMinutes.toFixed(1);
|
||
$("b", item).textContent = running
|
||
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
|
||
: "未运行";
|
||
return;
|
||
}
|
||
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
|
||
? { ...(previousSeat[feature] || {}), ...value }
|
||
: value;
|
||
});
|
||
mergedState[seat] = mergedSeat;
|
||
});
|
||
return mergedState;
|
||
}
|
||
|
||
function renderSeatState(actuatorState) {
|
||
if (!actuatorState) return;
|
||
const mergedActuatorState = mergeSeatActuatorState(
|
||
state.seatActuatorState,
|
||
actuatorState,
|
||
);
|
||
state.seatActuatorState = mergedActuatorState;
|
||
resetSeatState();
|
||
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
|
||
const displayFunctions = seat === "driver"
|
||
? twinDeviceForSeat("driver", { driver: functions })
|
||
: functions;
|
||
const seatCard = $(`.seat-card[data-seat="${seat}"]`);
|
||
Object.entries(displayFunctions).forEach(([feature, value]) => {
|
||
if (seatCard && ["ventilation", "heating"].includes(feature)) {
|
||
seatCard.dataset[`${feature}State`] = String(value?.state || "unknown");
|
||
seatCard.dataset[`${feature}Level`] = String(value?.level ?? 0);
|
||
} else if (seatCard && feature === "massage") {
|
||
const mode = String(value?.mode || "off");
|
||
const strength = String(value?.strength || "off");
|
||
seatCard.dataset.massageState = (
|
||
!["off", "unknown", ""].includes(mode)
|
||
|| !["off", "unknown", ""].includes(strength)
|
||
) ? "on" : "off";
|
||
}
|
||
if (feature === "position_feedback" && seat === "driver") {
|
||
const status = $('.seat-card[data-seat="driver"] [data-dynamic-mode] b');
|
||
const initialized = Object.values(value.initialized || {}).filter(Boolean).length;
|
||
if (status) status.textContent = value.absolute_position_available === false
|
||
? `0x494 初始化 ${initialized}/4 · 无绝对位置`
|
||
: "CH1 状态已同步";
|
||
return;
|
||
}
|
||
if (feature === "position") {
|
||
Object.entries(value).forEach(([axis, motion]) => {
|
||
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
|
||
if (!item) return;
|
||
const position = Number(motion.position_pct || 0);
|
||
const estimated = Boolean(
|
||
motion.position_estimated || motion.position_valid === false
|
||
);
|
||
$("b", item).textContent = `${estimated ? "≈" : ""}${position}%`;
|
||
item.title = estimated ? "Hall 已确认相对运动;绝对位置尚未标定" : "";
|
||
$("i", item).style.width = `${Math.max(0, Math.min(100, position))}%`;
|
||
item.classList.toggle("fault", Boolean(motion.fault));
|
||
});
|
||
return;
|
||
}
|
||
const item = $(`.seat-card[data-seat="${seat}"] [data-function="${feature}"]`);
|
||
if (!item) return;
|
||
if (feature === "massage") {
|
||
const mode = String(value.mode || "off");
|
||
const strength = String(value.strength || "off");
|
||
const active = !["off", "unknown", ""].includes(mode)
|
||
|| !["off", "unknown", ""].includes(strength);
|
||
item.classList.toggle("on", active);
|
||
$("b", item).textContent = active
|
||
? `${mode.toUpperCase()} · ${strength.toUpperCase()}`
|
||
: "OFF";
|
||
return;
|
||
}
|
||
item.classList.toggle("on", value.state === "on");
|
||
$("b", item).textContent = value.state === "on" ? `ON · L${value.level}` : "OFF";
|
||
});
|
||
});
|
||
updateSeatTwin(mergedActuatorState);
|
||
}
|
||
|
||
function renderDomainState(domainState) {
|
||
if (!domainState) return;
|
||
const outletModeLabel = (value) => {
|
||
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
|
||
const matrixLabels = {
|
||
face: "吹面",
|
||
face_feet: "吹面 / 吹脚",
|
||
feet: "吹脚",
|
||
feet_defrost: "吹脚 / 除霜",
|
||
defrost: "除霜",
|
||
face_feet_defrost: "吹面 / 吹脚 / 除霜",
|
||
face_defrost: "吹面 / 除霜",
|
||
};
|
||
if (matrixLabels[normalized]) return matrixLabels[normalized];
|
||
if (["avoid_people", "avoid_person", "avoid_person_blow", "avoid_occupant"].includes(normalized)) {
|
||
return "避人吹";
|
||
}
|
||
if (["sweep", "sweeping", "oscillate", "oscillation"].includes(normalized)) {
|
||
return "扫风";
|
||
}
|
||
return "-";
|
||
};
|
||
const onOffLabel = (value) => {
|
||
if ([true, 1, "1", "on", "open", "opened", "active"].includes(value)) return "开启";
|
||
if ([false, 0, "0", "off", "close", "closed", "inactive"].includes(value)) return "关闭";
|
||
return "未知";
|
||
};
|
||
const percentageValue = (value) => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? Math.max(0, Math.min(100, numeric)) : 0;
|
||
};
|
||
const climate = domainState.climate;
|
||
if (climate) {
|
||
const powerLabel = onOffLabel(climate.power);
|
||
const acLabel = onOffLabel(climate.ac);
|
||
const defrostLabel = onOffLabel(climate.front_defrost);
|
||
const fanValue = Number(climate.fan_level);
|
||
const hasFanLevel = climate.fan_level !== null
|
||
&& climate.fan_level !== undefined
|
||
&& climate.fan_level !== ""
|
||
&& Number.isFinite(fanValue);
|
||
const active = powerLabel === "开启" || acLabel === "开启" || (hasFanLevel && fanValue > 0);
|
||
const driverTemperature = climate.zones?.driver?.temperature_c
|
||
?? climate.zones?.vehicle?.temperature_c;
|
||
const passengerTemperature = climate.zones?.passenger?.temperature_c
|
||
?? climate.zones?.vehicle?.temperature_c;
|
||
const hasDriverTemperature = Number.isFinite(Number(driverTemperature));
|
||
const hasPassengerTemperature = Number.isFinite(Number(passengerTemperature));
|
||
$('[data-domain-panel="climate"]').classList.toggle("active", active);
|
||
$("#climatePower").textContent = powerLabel === "开启"
|
||
? "ON"
|
||
: powerLabel === "关闭" ? (active ? "ACTIVE" : "OFF") : active ? "ACTIVE" : "UNKNOWN";
|
||
$("#driverTemperature").innerHTML = hasDriverTemperature
|
||
? `${driverTemperature}<sup>℃</sup>`
|
||
: "--<sup>℃</sup>";
|
||
$("#passengerTemperature").innerHTML = hasPassengerTemperature
|
||
? `${passengerTemperature}<sup>℃</sup>`
|
||
: "--<sup>℃</sup>";
|
||
$("#driverAirDirection").textContent = outletModeLabel(climate.zones?.driver?.outlet_mode);
|
||
$("#passengerAirDirection").textContent = outletModeLabel(climate.zones?.passenger?.outlet_mode);
|
||
$("#airflowVisual").dataset.level = String(
|
||
hasFanLevel ? Math.max(0, Math.min(7, fanValue)) : 0,
|
||
);
|
||
$("#fanLevel").textContent = hasFanLevel ? `${fanValue} 档` : "未知";
|
||
$("#cycleMode").textContent = {
|
||
internal: "内循环",
|
||
external: "外循环",
|
||
auto: "自动循环",
|
||
}[climate.cycle_mode] || "未知";
|
||
$("#acState").textContent = acLabel;
|
||
$("#defrostState").textContent = defrostLabel;
|
||
const vehicleSystems = state.seatTwin?.vehicleSystems;
|
||
if (vehicleSystems) {
|
||
if (hasFanLevel) {
|
||
vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, fanValue));
|
||
}
|
||
const activeTemperature = Number(
|
||
selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
|
||
? driverTemperature
|
||
: passengerTemperature,
|
||
);
|
||
if (Number.isFinite(activeTemperature)) {
|
||
vehicleSystems.targets.climateTemperature = activeTemperature;
|
||
}
|
||
}
|
||
}
|
||
|
||
const roof = domainState.roof;
|
||
if (roof) {
|
||
const transmittance = Math.max(0, Math.min(100, Number(roof.transmittance_pct) || 0));
|
||
const shadePosition = Math.max(0, Math.min(100, Number(roof.shade_position_pct) || 0));
|
||
$('[data-domain-panel="roof"]').classList.toggle(
|
||
"active",
|
||
transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on",
|
||
);
|
||
const roofGlass = $("#roofGlass");
|
||
const roofShade = $("#roofShade");
|
||
if (roofGlass) roofGlass.style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`;
|
||
if (roofShade) roofShade.style.width = `${100 - shadePosition}%`;
|
||
$("#shadePositionValue").textContent = `${shadePosition}%`;
|
||
const shadePositionMeter = $("#shadePositionMeter");
|
||
if (shadePositionMeter) shadePositionMeter.style.width = `${shadePosition}%`;
|
||
const shadeMotionLabels = {
|
||
stopped: "停止",
|
||
opening: "打开中",
|
||
closing: "关闭中",
|
||
anti_pinch: "防夹",
|
||
unknown: "未知",
|
||
};
|
||
$("#shadeMotionState").textContent = shadeMotionLabels[roof.motion_state] || "未知";
|
||
$("#shadeInitializedState").textContent = roof.initialized === true
|
||
? "已初始化"
|
||
: roof.initialized === false ? "未初始化" : "未知";
|
||
$("#shadeThermalState").textContent = roof.thermal_protection === true ? "保护中" : "正常";
|
||
$("#roofState").textContent = roof.fault ? "FAULT" : "READY";
|
||
const vehicleSystems = state.seatTwin?.vehicleSystems;
|
||
if (vehicleSystems) {
|
||
vehicleSystems.targets.roofTransmittance = transmittance;
|
||
vehicleSystems.targets.roofShadePosition = shadePosition;
|
||
}
|
||
}
|
||
|
||
const vehicleStatus = domainState.vehicle_status || domainState.vehicle_safety;
|
||
const door = domainState.door;
|
||
if (vehicleStatus || door) {
|
||
const panel = $('[data-domain-panel="vehicle_status"]');
|
||
const gear = String(vehicleStatus?.gear || "unknown").toUpperCase();
|
||
const speedValue = vehicleStatus?.speed_kmh;
|
||
const speed = Number(speedValue);
|
||
const hasGear = ["P", "R", "N", "D"].includes(gear);
|
||
const hasSpeed = speedValue !== null
|
||
&& speedValue !== undefined
|
||
&& speedValue !== ""
|
||
&& Number.isFinite(speed);
|
||
$("#vehicleGear").textContent = hasGear ? gear : "--";
|
||
$("#vehicleSpeed").textContent = hasSpeed
|
||
? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h`
|
||
: "-- km/h";
|
||
|
||
const doorLabels = { lock: "已锁", unlock: "未锁", unknown: "未知" };
|
||
const doorIds = {
|
||
front_left: "frontLeftDoorState",
|
||
front_right: "frontRightDoorState",
|
||
rear_left: "rearLeftDoorState",
|
||
rear_right: "rearRightDoorState",
|
||
};
|
||
let knownDoorCount = 0;
|
||
let lockedDoorCount = 0;
|
||
Object.entries(doorIds).forEach(([name, id]) => {
|
||
const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase();
|
||
if (lockState !== "unknown") knownDoorCount += 1;
|
||
if (lockState === "lock") lockedDoorCount += 1;
|
||
$(`#${id}`).textContent = doorLabels[lockState] || "未知";
|
||
const row = panel?.querySelector(`[data-door-state="${name}"]`);
|
||
if (row) row.dataset.state = lockState;
|
||
});
|
||
const feedbackSource = vehicleStatus?.feedback_source || door?.feedback_source;
|
||
const hasAuthoritativeState = isAuthoritativeCh1Source(feedbackSource);
|
||
const wirelessState = feedbackSource === "wireless_mqtt_ch1";
|
||
panel?.classList.toggle("active", hasAuthoritativeState);
|
||
$("#vehicleTelemetryState").textContent = hasAuthoritativeState
|
||
? (hasGear && hasSpeed && knownDoorCount === 4
|
||
? (wirelessState ? "MQTT 实时" : "实时")
|
||
: (wirelessState ? "MQTT 部分" : "部分"))
|
||
: "只读";
|
||
const summaryGear = $("#cabinSummaryGear");
|
||
const summarySpeed = $("#cabinSummarySpeed");
|
||
const summaryDoors = $("#cabinSummaryDoors");
|
||
const summarySystem = $("#cabinSummarySystem");
|
||
if (summaryGear) summaryGear.textContent = hasGear ? `${gear} 挡` : "--";
|
||
if (summarySpeed) summarySpeed.textContent = hasSpeed
|
||
? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h`
|
||
: "-- km/h";
|
||
if (summaryDoors) summaryDoors.textContent = knownDoorCount < 4
|
||
? "状态未知"
|
||
: lockedDoorCount === 4 ? "四门已锁" : `${lockedDoorCount} 门已锁`;
|
||
if (summarySystem) {
|
||
const faulted = Boolean(vehicleStatus?.fault || door?.fault);
|
||
summarySystem.textContent = faulted
|
||
? "需检查"
|
||
: hasAuthoritativeState ? "CAN 实时" : "等待状态";
|
||
summarySystem.dataset.state = faulted
|
||
? "warning"
|
||
: hasAuthoritativeState ? "healthy" : "waiting";
|
||
}
|
||
}
|
||
|
||
const ambient = domainState.ambient_light;
|
||
if (ambient) {
|
||
const active = ambient.power === "on";
|
||
const authoritativeAmbient = isAuthoritativeCh1Source(ambient.feedback_source);
|
||
const indexedPhysicalColor = authoritativeAmbient
|
||
&& Number.isInteger(ambient.front_color_index);
|
||
const color = indexedPhysicalColor
|
||
? "#D6DCE0"
|
||
: authoritativeAmbient ? "#B8C2C5"
|
||
: /^#[0-9A-Fa-f]{6}$/.test(ambient.color_hex || "")
|
||
? ambient.color_hex.toUpperCase()
|
||
: "#74D9FF";
|
||
const physicalBrightness = Number(ambient.front_brightness_level);
|
||
const brightness = Math.max(0, Math.min(100,
|
||
Number.isFinite(physicalBrightness) && isAuthoritativeCh1Source(ambient.feedback_source)
|
||
? physicalBrightness * 20
|
||
: Number(ambient.brightness_pct) || 0,
|
||
));
|
||
const opacity = active ? Math.max(0.18, brightness / 100) : 0.08;
|
||
const modeLabels = {
|
||
camping: "露营",
|
||
rest: "小憩",
|
||
cinema: "观影",
|
||
meditation: "冥想",
|
||
romantic: "浪漫",
|
||
custom: "自定义",
|
||
on: "开启",
|
||
off: "关闭",
|
||
};
|
||
const lightPanel = $('[data-domain-panel="ambient_light"]');
|
||
lightPanel.dataset.ambientPower = ambient.power || "unknown";
|
||
$$(".ambient-line").forEach((line) => {
|
||
line.style.background = color;
|
||
line.style.boxShadow = `0 0 ${Math.round(5 + brightness / 8)}px ${color}`;
|
||
line.style.opacity = String(opacity);
|
||
});
|
||
$("#ambientMode").textContent = indexedPhysicalColor
|
||
? (active ? "开启(车端色号)" : "关闭")
|
||
: authoritativeAmbient
|
||
? (active ? "开启(色号无效)" : ambient.power === "off" ? "关闭" : "状态未知")
|
||
: modeLabels[ambient.mode] || ambient.mode || "自定义";
|
||
$("#ambientBrightness").textContent = `${ambient.front_brightness_level ?? "-"} / ${ambient.rear_brightness_level ?? "-"}`;
|
||
$("#ambientColorIndex").textContent = `${ambient.front_color_index ?? "-"} / ${ambient.rear_color_index ?? "-"}`;
|
||
[
|
||
["ambientFrontMeter", ambient.front_brightness_level],
|
||
["ambientRearMeter", ambient.rear_brightness_level],
|
||
].forEach(([id, level]) => {
|
||
const meter = $(`#${id}`);
|
||
if (!meter) return;
|
||
const normalizedLevel = Math.max(0, Math.min(5, Number(level) || 0));
|
||
meter.style.background = color;
|
||
meter.style.opacity = String(active ? 0.28 + normalizedLevel * 0.144 : 0.18);
|
||
meter.style.boxShadow = active
|
||
? `0 0 ${3 + normalizedLevel * 1.5}px ${color}`
|
||
: "none";
|
||
});
|
||
const vehicleSystems = state.seatTwin?.vehicleSystems;
|
||
if (vehicleSystems) {
|
||
vehicleSystems.targets.ambientPower = active ? 1 : 0;
|
||
vehicleSystems.targets.ambientBrightness = brightness;
|
||
vehicleSystems.colors.ambientTarget.set(color);
|
||
}
|
||
}
|
||
|
||
const interiorLight = domainState.interior_light;
|
||
if (interiorLight) {
|
||
const lightPanel = $('[data-domain-panel="ambient_light"]');
|
||
lightPanel.dataset.interiorPower = interiorLight.power || "unknown";
|
||
$("#interiorLightPower").textContent = onOffLabel(interiorLight.power);
|
||
}
|
||
if (ambient || interiorLight) {
|
||
const lightPanel = $('[data-domain-panel="ambient_light"]');
|
||
const ambientOn = lightPanel.dataset.ambientPower === "on";
|
||
const interiorOn = lightPanel.dataset.interiorPower === "on";
|
||
lightPanel.classList.toggle("active", ambientOn || interiorOn);
|
||
const ambientKnown = ["on", "off"].includes(lightPanel.dataset.ambientPower);
|
||
const interiorKnown = ["on", "off"].includes(lightPanel.dataset.interiorPower);
|
||
$("#ambientPower").textContent = ambientOn
|
||
? "AMBIENT"
|
||
: interiorOn ? "INTERIOR" : ambientKnown && interiorKnown ? "OFF" : "UNKNOWN";
|
||
}
|
||
|
||
const windowState = domainState.window || domainState.windows;
|
||
if (windowState) {
|
||
const readWindowFeedback = (key) => {
|
||
const source = windowState.positions?.[key]
|
||
?? windowState.windows?.[key]
|
||
?? windowState[key];
|
||
const rawPosition = source?.position_pct ?? source?.opening_pct ?? source?.position;
|
||
const numericPosition = Number(rawPosition);
|
||
return {
|
||
position: rawPosition !== null
|
||
&& rawPosition !== undefined
|
||
&& rawPosition !== ""
|
||
&& Number.isFinite(numericPosition)
|
||
? percentageValue(numericPosition)
|
||
: null,
|
||
closed: typeof source?.closed === "boolean" ? source.closed : null,
|
||
};
|
||
};
|
||
const windowFeedback = {
|
||
frontLeftWindow: readWindowFeedback("front_left"),
|
||
frontRightWindow: readWindowFeedback("front_right"),
|
||
rearLeftWindow: readWindowFeedback("rear_left"),
|
||
rearRightWindow: readWindowFeedback("rear_right"),
|
||
};
|
||
Object.entries(windowFeedback).forEach(([id, feedback]) => {
|
||
$(`#${id}`).textContent = feedback.position !== null
|
||
? `${feedback.position}%`
|
||
: feedback.closed === true ? "已关闭" : feedback.closed === false ? "未关闭" : "未知";
|
||
const windowKey = {
|
||
frontLeftWindow: "front_left",
|
||
frontRightWindow: "front_right",
|
||
rearLeftWindow: "rear_left",
|
||
rearRightWindow: "rear_right",
|
||
}[id];
|
||
const meter = $(`[data-window-meter="${windowKey}"]`);
|
||
if (meter) {
|
||
meter.dataset.state = feedback.closed === true
|
||
? "closed"
|
||
: feedback.closed === false ? "open" : "unknown";
|
||
if (feedback.position !== null) {
|
||
meter.style.setProperty("--window-open", `${feedback.position}%`);
|
||
} else {
|
||
meter.style.removeProperty("--window-open");
|
||
}
|
||
}
|
||
});
|
||
const knownWindowFeedback = Object.values(windowFeedback).filter(
|
||
(feedback) => feedback.position !== null || feedback.closed !== null,
|
||
);
|
||
const anyWindowOpen = knownWindowFeedback.some(
|
||
(feedback) => feedback.closed === false
|
||
|| (feedback.position !== null && feedback.position > 0),
|
||
);
|
||
const allWindowsClosed = knownWindowFeedback.length === 4
|
||
&& knownWindowFeedback.every(
|
||
(feedback) => feedback.closed === true || feedback.position === 0,
|
||
);
|
||
$('[data-domain-panel="window"]').classList.toggle("active", anyWindowOpen);
|
||
$("#windowState").textContent = anyWindowOpen ? "OPEN" : allWindowsClosed ? "CLOSED" : "UNKNOWN";
|
||
const vehicleSystems = state.seatTwin?.vehicleSystems;
|
||
if (vehicleSystems) {
|
||
Object.entries(windowFeedback).forEach(([id, feedback]) => {
|
||
const windowKey = {
|
||
frontLeftWindow: "front_left",
|
||
frontRightWindow: "front_right",
|
||
rearLeftWindow: "rear_left",
|
||
rearRightWindow: "rear_right",
|
||
}[id];
|
||
if (feedback.position !== null) {
|
||
vehicleSystems.targets.windowPositions[windowKey] = feedback.position;
|
||
} else if (feedback.closed === true) {
|
||
vehicleSystems.targets.windowPositions[windowKey] = 0;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const frontLight = domainState.front_light
|
||
|| domainState.front_lights
|
||
|| domainState.exterior_light
|
||
|| domainState.headlight;
|
||
if (frontLight) {
|
||
const readLightState = (...keys) => {
|
||
const key = keys.find((candidate) => frontLight[candidate] !== undefined);
|
||
const source = key ? frontLight[key] : undefined;
|
||
return source?.state ?? source?.power ?? source;
|
||
};
|
||
const lightStates = {
|
||
lowBeamState: readLightState("low_beam", "dipped_beam"),
|
||
highBeamState: readLightState("high_beam", "main_beam"),
|
||
fogLightState: readLightState("fog_light", "fog"),
|
||
turnSignalState: readLightState("turn_signal", "indicator"),
|
||
};
|
||
Object.entries(lightStates).forEach(([id, value]) => {
|
||
const label = onOffLabel(value);
|
||
$(`#${id}`).textContent = label;
|
||
const meter = $(`[data-light-meter="${id}"]`);
|
||
if (meter) meter.dataset.state = label === "开启" ? "on" : label === "关闭" ? "off" : "unknown";
|
||
});
|
||
const lightLabels = Object.values(lightStates).map(onOffLabel);
|
||
const anyLightOn = lightLabels.includes("开启");
|
||
const hasKnownLight = lightLabels.some((value) => value !== "未知");
|
||
$('[data-domain-panel="front_light"]').classList.toggle("active", anyLightOn);
|
||
$("#frontLightState").textContent = anyLightOn ? "ON" : hasKnownLight ? "OFF" : "UNKNOWN";
|
||
const vehicleSystems = state.seatTwin?.vehicleSystems;
|
||
if (vehicleSystems) {
|
||
[
|
||
["lowBeam", lightStates.lowBeamState],
|
||
["highBeam", lightStates.highBeamState],
|
||
["fogLight", lightStates.fogLightState],
|
||
["turnSignal", lightStates.turnSignalState],
|
||
].forEach(([target, value]) => {
|
||
const label = onOffLabel(value);
|
||
if (label !== "未知") vehicleSystems.targets[target] = label === "开启" ? 1 : 0;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderedConfig(result) {
|
||
if (result.scene_config) {
|
||
return {
|
||
scene_config: result.scene_config,
|
||
function_configs: result.function_configs,
|
||
};
|
||
}
|
||
return result.preset_config || result.function_config;
|
||
}
|
||
|
||
function renderedPlan(result) {
|
||
const plans = result.command_plans || {};
|
||
return Object.keys(plans).length > 1 ? plans : result.command_plan;
|
||
}
|
||
|
||
function renderSavedPresets(result) {
|
||
const indicator = $("#presetIndicator");
|
||
const presets = result.saved_presets || [];
|
||
if (!presets.length) {
|
||
indicator.hidden = true;
|
||
return;
|
||
}
|
||
const latest = result.preset_config
|
||
? {
|
||
name: result.preset_config.metadata.name,
|
||
target: result.preset_config.metadata.labels.target,
|
||
}
|
||
: presets[presets.length - 1];
|
||
const targetName = latest.target === "driver" ? "主驾" : "副驾";
|
||
$("span", indicator).textContent = `已保存 ${targetName} · ${latest.name}`;
|
||
indicator.hidden = false;
|
||
}
|
||
|
||
const CONFIG_DOMAIN_META = {
|
||
seat: { label: "副驾座椅", icon: "armchair", className: "seat" },
|
||
climate: { label: "空调", icon: "fan", className: "climate" },
|
||
ambient_light: { label: "氛围灯", icon: "lamp-ceiling", className: "ambient" },
|
||
roof: { label: "智能天幕", icon: "panels-top-left", className: "roof" },
|
||
door: { label: "车门锁", icon: "door-open", className: "generic" },
|
||
window: { label: "车窗", icon: "panels-top-left", className: "generic" },
|
||
outlet: { label: "电动风口", icon: "wind", className: "climate" },
|
||
interior_light: { label: "车内灯", icon: "lightbulb", className: "ambient" },
|
||
};
|
||
|
||
const CONFIG_CAPABILITY_LABELS = {
|
||
"seat.set_ventilation": "设置座椅通风",
|
||
"seat.set_heating": "设置座椅加热",
|
||
"seat.set_longitudinal": "调节座椅前后位置",
|
||
"seat.set_longitudinal_position": "调节座椅前后位置",
|
||
"seat.set_cushion_tilt": "调节座椅高度",
|
||
"seat.set_backrest_recline": "调节靠背角度",
|
||
"seat.set_legrest_height": "调节腿托角度",
|
||
"seat.set_legrest_extension": "调节腿托伸缩",
|
||
"seat.start_dynamic_mode": "启动动态座椅模式",
|
||
"seat.stop_dynamic_mode": "停止动态座椅模式",
|
||
"climate.set_temperature": "设置空调温度",
|
||
"climate.set_fan_level": "设置空调风量",
|
||
"climate.set_power": "设置空调电源",
|
||
"climate.set_ac": "设置 A/C",
|
||
"climate.set_cycle_mode": "设置内外循环",
|
||
"climate.set_outlet_mode": "设置出风模式",
|
||
"climate.set_front_defrost": "设置前风挡除霜",
|
||
"climate.set_control_mode": "设置空调控制模式",
|
||
"door.set_vehicle_lock": "整车门锁(当前 CH1 仅观测)",
|
||
"door.set_rear_lock": "二排门锁(当前 CH1 仅观测、已停用)",
|
||
"window.set_motion": "控制车窗动作",
|
||
"window.set_position": "设置车窗开度",
|
||
"outlet.set_position_percent": "设置电动风口位置",
|
||
"outlet.set_switch_state": "开关单个电动风口",
|
||
"outlet.set_calibrated_position": "设置电动风口标定位置",
|
||
"interior_light.set_indoor_power": "设置室内灯",
|
||
"interior_light.set_wall_power": "设置壁灯",
|
||
"interior_light.set_reading_power": "设置阅读灯",
|
||
"ambient_light.set_power": "设置氛围灯电源",
|
||
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
|
||
"ambient_light.set_color": "设置氛围灯颜色",
|
||
"ambient_light.set_brightness": "设置氛围灯亮度",
|
||
"ambient_light.set_mode": "设置氛围灯模式",
|
||
"roof.set_transmittance": "设置天幕透光率",
|
||
"roof.set_shade_position": "设置遮阳帘位置",
|
||
"roof.set_shade_motion": "控制遮阳帘动作",
|
||
"roof.set_automatic_dimming": "设置自动调光",
|
||
};
|
||
|
||
const CONFIG_PARAMETER_LABELS = {
|
||
power: "电源",
|
||
state: "状态",
|
||
level: "档位",
|
||
temperature_c: "目标温度",
|
||
fan_level: "目标风量",
|
||
position_pct: "目标位置",
|
||
angle_deg: "目标角度",
|
||
color_hex: "目标颜色",
|
||
brightness_pct: "目标亮度",
|
||
transmittance_pct: "目标透光率",
|
||
shade_position_pct: "遮阳帘位置",
|
||
automatic_dimming: "自动调光",
|
||
cycle_mode: "循环模式",
|
||
outlet_mode: "出风模式",
|
||
mode: "工作模式",
|
||
mode_id: "固件模式编号",
|
||
};
|
||
|
||
function makeConfigElement(tagName, className, textValue) {
|
||
const element = document.createElement(tagName);
|
||
if (className) element.className = className;
|
||
if (textValue != null) element.textContent = textValue;
|
||
return element;
|
||
}
|
||
|
||
function configTargetLabel(target = {}, domain) {
|
||
const scopes = {
|
||
driver: "主驾",
|
||
passenger: "副驾",
|
||
front_left: "主驾",
|
||
front_right: "副驾",
|
||
front_row: "前排",
|
||
rear_row: "后排",
|
||
vehicle: "全车",
|
||
cabin: "全车舱",
|
||
};
|
||
const rawScope = target.instance || target.zone || (domain === "seat" ? "passenger" : "vehicle");
|
||
const scope = scopes[rawScope] || rawScope;
|
||
if (domain === "seat") return `${scope}座椅`;
|
||
const device = CONFIG_DOMAIN_META[domain]?.label || target.device || domain;
|
||
return `${scope} · ${device}`;
|
||
}
|
||
|
||
function formatConfigValue(name, value) {
|
||
const stateLabels = {
|
||
on: "开启",
|
||
off: "关闭",
|
||
internal: "内循环",
|
||
external: "外循环",
|
||
face: "面部出风",
|
||
feet: "脚部出风",
|
||
cinema: "观影",
|
||
rest: "小憩",
|
||
custom: "自定义",
|
||
};
|
||
if (typeof value === "string" && stateLabels[value]) return stateLabels[value];
|
||
if (name === "temperature_c") return `${value} ℃`;
|
||
if (name === "angle_deg") return `${value}°`;
|
||
if (name === "fan_level" || name === "level") return `${value} 档`;
|
||
if (name.endsWith("_pct")) return `${value}%`;
|
||
if (typeof value === "boolean") return value ? "开启" : "关闭";
|
||
if (value && typeof value === "object") return JSON.stringify(value);
|
||
return String(value ?? "--");
|
||
}
|
||
|
||
function configEntries(result) {
|
||
const configured = result.function_configs || {};
|
||
if (Object.keys(configured).length) return Object.entries(configured);
|
||
const single = result.function_config || result.preset_config;
|
||
const domain = single?.spec?.domain || "seat";
|
||
return single ? [[domain, single]] : [];
|
||
}
|
||
|
||
function planForDomain(result, domain) {
|
||
if (result.command_plans?.[domain]) return result.command_plans[domain];
|
||
return result.command_plan?.spec?.domain === domain ? result.command_plan : null;
|
||
}
|
||
|
||
function renderConfigCards(result) {
|
||
const container = $("#configCards");
|
||
container.replaceChildren();
|
||
const entries = configEntries(result);
|
||
const operations = entries.flatMap(([configKey, config]) => (
|
||
(config?.spec?.operations || []).map((operation) => ({
|
||
configKey,
|
||
domain: config?.spec?.domain || configKey,
|
||
config,
|
||
operation,
|
||
}))
|
||
));
|
||
if (!operations.length) {
|
||
const empty = makeConfigElement("div", "config-empty");
|
||
empty.innerHTML = '<i data-lucide="panel-top-dashed"></i><strong>暂无可展示的功能配置</strong><span>原始配置仍可在下方展开查看。</span>';
|
||
container.append(empty);
|
||
refreshIcons();
|
||
return 0;
|
||
}
|
||
|
||
const sceneSummary = makeConfigElement("div", "config-scene-summary");
|
||
const sceneCopy = makeConfigElement("div", "config-scene-copy");
|
||
const sceneIcon = makeConfigElement("span", "config-scene-icon");
|
||
sceneIcon.innerHTML = '<i data-lucide="workflow"></i>';
|
||
const sceneText = makeConfigElement("div", "");
|
||
sceneText.append(
|
||
makeConfigElement("strong", "", result.scene_config?.metadata?.name || "单域功能配置"),
|
||
makeConfigElement("span", "", result.scene_config ? "SceneConfig 联合场景" : "FunctionConfig 独立控制"),
|
||
);
|
||
sceneCopy.append(sceneIcon, sceneText);
|
||
const sceneStats = makeConfigElement("div", "config-scene-stats");
|
||
[[entries.length, "控制域"], [operations.length, "控制动作"], [result.validation?.length || 0, "校验提示"]].forEach(([value, label]) => {
|
||
const stat = makeConfigElement("span", "");
|
||
stat.append(makeConfigElement("b", "", value), makeConfigElement("small", "", label));
|
||
sceneStats.append(stat);
|
||
});
|
||
sceneSummary.append(sceneCopy, sceneStats);
|
||
container.append(sceneSummary);
|
||
|
||
operations.forEach(({ configKey, domain, config, operation }, index) => {
|
||
const meta = CONFIG_DOMAIN_META[domain] || { label: domain, icon: "box", className: "generic" };
|
||
const card = makeConfigElement("article", `config-card config-${meta.className}`);
|
||
const header = makeConfigElement("header", "config-card-header");
|
||
const identity = makeConfigElement("div", "config-card-identity");
|
||
const icon = makeConfigElement("span", "config-card-icon");
|
||
icon.innerHTML = `<i data-lucide="${meta.icon}"></i>`;
|
||
const title = makeConfigElement("div", "");
|
||
title.append(
|
||
makeConfigElement("small", "", `ACTION ${String(index + 1).padStart(2, "0")} · ${domain.toUpperCase()}`),
|
||
makeConfigElement("strong", "", CONFIG_CAPABILITY_LABELS[operation.capability] || operation.capability || meta.label),
|
||
);
|
||
identity.append(icon, title);
|
||
header.append(identity, makeConfigElement("span", "config-domain-badge", meta.label));
|
||
|
||
const objectBlock = makeConfigElement("section", "config-object-block");
|
||
objectBlock.append(
|
||
makeConfigElement("span", "config-block-label", "控制对象"),
|
||
makeConfigElement("strong", "config-object-name", configTargetLabel(operation.target, domain)),
|
||
makeConfigElement("code", "config-object-path", `${operation.target?.device || domain} / ${operation.target?.zone || operation.target?.instance || "vehicle"}`),
|
||
);
|
||
|
||
const parametersBlock = makeConfigElement("section", "config-parameters-block");
|
||
parametersBlock.append(makeConfigElement("span", "config-block-label", "目标参数"));
|
||
const parameterGrid = makeConfigElement("div", "config-parameter-grid");
|
||
const parameters = Object.entries(operation.parameters || {});
|
||
(parameters.length ? parameters : [["state", "--"]]).forEach(([name, value]) => {
|
||
const item = makeConfigElement("div", "config-parameter");
|
||
const valueNode = makeConfigElement("strong", "", formatConfigValue(name, value));
|
||
if (name === "color_hex" && /^#[0-9A-Fa-f]{6}$/.test(String(value))) {
|
||
const swatch = makeConfigElement("i", "config-color-swatch");
|
||
swatch.style.background = value;
|
||
valueNode.prepend(swatch);
|
||
}
|
||
item.append(
|
||
makeConfigElement("span", "", CONFIG_PARAMETER_LABELS[name] || name),
|
||
valueNode,
|
||
);
|
||
parameterGrid.append(item);
|
||
});
|
||
parametersBlock.append(parameterGrid);
|
||
|
||
const plan = planForDomain(result, configKey);
|
||
const allCommands = plan?.spec?.commands || [];
|
||
const matchedCommands = allCommands.filter((command) => !command.operation_ref || command.operation_ref === operation.id);
|
||
const command = matchedCommands[0] || null;
|
||
const hardwareBlock = makeConfigElement("section", `config-hardware-block ${command ? "mapped" : "unmapped"}`);
|
||
hardwareBlock.append(makeConfigElement("span", "config-block-label", "硬件映射"));
|
||
const route = makeConfigElement("div", "config-hardware-route");
|
||
const from = makeConfigElement("div", "config-route-node");
|
||
from.append(makeConfigElement("small", "", "逻辑配置"), makeConfigElement("strong", "", operation.id || "operation"));
|
||
const arrow = makeConfigElement("i", "config-route-arrow");
|
||
arrow.innerHTML = '<i data-lucide="arrow-right"></i>';
|
||
const to = makeConfigElement("div", "config-route-node target");
|
||
to.append(
|
||
makeConfigElement("small", "", command?.bus || plan?.spec?.platform || "待编译"),
|
||
makeConfigElement("strong", "", command?.message || "暂无 CAN 命令"),
|
||
);
|
||
route.append(from, arrow, to);
|
||
hardwareBlock.append(route);
|
||
const signals = Object.keys(command?.signals || {});
|
||
const signalList = makeConfigElement("div", "config-signal-list");
|
||
if (signals.length) {
|
||
signals.slice(0, 5).forEach((signal) => signalList.append(makeConfigElement("code", "", signal)));
|
||
if (signals.length > 5) signalList.append(makeConfigElement("code", "config-signal-more", `+${signals.length - 5}`));
|
||
} else {
|
||
signalList.append(makeConfigElement("span", "config-unmapped-note", "虚拟状态可预览,等待硬件编译映射"));
|
||
}
|
||
hardwareBlock.append(signalList);
|
||
|
||
const footer = makeConfigElement("footer", "config-card-footer");
|
||
const executionScope = config.metadata?.labels?.execution_scope;
|
||
const executionScopeLabel = result.execution_mode === "hardware" && domain === "seat"
|
||
? "座椅 CAN · CH0"
|
||
: executionScope === "vehicle_can_channel_1"
|
||
? "车身 CAN · CH1"
|
||
: executionScope === "virtual_only" ? "仅虚拟执行" : "已通过配置校验";
|
||
footer.append(
|
||
makeConfigElement("span", "", `超时 ${operation.timeout_ms ?? "--"} ms`),
|
||
makeConfigElement("span", "", operation.failure_policy === "continue" ? "失败后继续" : "失败即中止"),
|
||
makeConfigElement("span", "", executionScopeLabel),
|
||
);
|
||
|
||
card.append(header, objectBlock, parametersBlock, hardwareBlock, footer);
|
||
container.append(card);
|
||
});
|
||
refreshIcons();
|
||
return operations.length;
|
||
}
|
||
|
||
function renderCode(result) {
|
||
const config = renderedConfig(result);
|
||
$("#configCode code").textContent = toYaml(config);
|
||
const configCardCount = renderConfigCards(result);
|
||
const configDomains = Object.keys(result.function_configs || {});
|
||
$("#configFileName").textContent = result.scene_config
|
||
? `scene.yaml + functions/{${configDomains.join(",")}}.yaml`
|
||
: result.preset_config
|
||
? "presets/seat.yaml"
|
||
: `functions/${configDomains[0] || "seat"}.yaml`;
|
||
$("#configNavMeta").textContent = `${configCardCount} 个控制动作`;
|
||
$("#planCode code").textContent = toYaml(renderedPlan(result));
|
||
const planDomains = Object.keys(result.command_plans || {});
|
||
$("#planFileName").textContent = planDomains.length > 1
|
||
? `command-plans/{${planDomains.join(",")}}.yaml`
|
||
: `command-plans/${planDomains[0] || "seat"}.yaml`;
|
||
|
||
const resultItems = result.execution_report?.spec?.results || [];
|
||
const telemetry = resultItems.find((item) => item.telemetry?.feedback_frame)?.telemetry
|
||
|| resultItems[0]?.telemetry;
|
||
const frames = resultItems
|
||
.map((item) => item.telemetry?.command_frame)
|
||
.filter(Boolean);
|
||
const summary = $("#frameSummary");
|
||
if (frames.length) {
|
||
const feedback = telemetry?.feedback_frame
|
||
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
|
||
: "NO DIRECT FEEDBACK";
|
||
const simulatedHil = result.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||
const mode = simulatedHil
|
||
? "HIL · SIMULATED"
|
||
: result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN";
|
||
summary.innerHTML = `<span>Frames</span><b>${frames.length}</b><span>First payload</span><code>${frames[0].frame_id} · ${frames[0].data_hex}</code><span>Feedback</span><code>${feedback}</code><span>Mode</span><b>${mode}</b>`;
|
||
$("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`;
|
||
} else if (telemetry?.operation === "save_posture_preset") {
|
||
summary.innerHTML = `<span>Operation</span><b>PRESET STORE</b><span>CAN</span><code>NO FRAME EMITTED</code><span>Scope</span><b>CONVERSATION</b>`;
|
||
$("#planNavMeta").textContent = "预设存储 · 无 CAN 帧";
|
||
} else {
|
||
summary.innerHTML = "<span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>NOT EXECUTED</b>";
|
||
$("#planNavMeta").textContent = "未生成 CAN 帧";
|
||
}
|
||
}
|
||
|
||
function operationVerificationStatus(item = {}) {
|
||
const explicit = String(item.telemetry?.verification_status || "");
|
||
if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) {
|
||
return explicit;
|
||
}
|
||
if (item.status !== "succeeded") return "failed_or_blocked";
|
||
if (item.telemetry?.physical_effect_confirmed === true || item.telemetry?.feedback_frame) {
|
||
return "verified_success";
|
||
}
|
||
if (item.telemetry?.command_frame
|
||
|| ["submitted", "queued", "published"].includes(item.telemetry?.delivery_status)) {
|
||
return "submitted_unverified";
|
||
}
|
||
return "verified_success";
|
||
}
|
||
|
||
function executionVerificationStatus(result = {}) {
|
||
const explicit = String(
|
||
result.execution_outcome
|
||
|| result.execution_report?.spec?.verification_status
|
||
|| "",
|
||
);
|
||
if (["verified_success", "submitted_unverified", "failed_or_blocked"].includes(explicit)) {
|
||
return explicit;
|
||
}
|
||
const operations = result.execution_report?.spec?.results || [];
|
||
const statuses = operations.map(operationVerificationStatus);
|
||
if (statuses.includes("failed_or_blocked")) return "failed_or_blocked";
|
||
if (statuses.includes("submitted_unverified")) return "submitted_unverified";
|
||
return "verified_success";
|
||
}
|
||
|
||
function renderExecution(result) {
|
||
const summary = $("#executionSummary");
|
||
const list = $("#resultList");
|
||
list.replaceChildren();
|
||
const report = result.execution_report;
|
||
if (!report) {
|
||
summary.className = "execution-summary failed";
|
||
const compileIssue = result.validation?.find((issue) => issue.code === "CAN_COMPILE_REJECTED");
|
||
const blockedReason = {
|
||
needs_confirmation: "请求需要补充确认",
|
||
capability_unavailable: "目标能力已识别,但对应域尚未接入执行器",
|
||
compile_failed: compileIssue?.message || "CAN 命令编译被安全约束阻止",
|
||
}[result.status] || "配置或语义校验未通过";
|
||
summary.innerHTML = `<i data-lucide="circle-x"></i><div><strong>执行未开始</strong><span>${blockedReason}</span></div>`;
|
||
$("#resultNavMeta").textContent = "未执行 · 查看原因";
|
||
$("#resultPanelState").textContent = "BLOCKED";
|
||
$("#resultPanelState").className = "workspace-panel-state failed";
|
||
refreshIcons();
|
||
return;
|
||
}
|
||
const verificationStatus = executionVerificationStatus(result);
|
||
const submittedUnverified = verificationStatus === "submitted_unverified";
|
||
const reportSucceeded = report.spec.status === "succeeded";
|
||
const partiallySucceeded = result.status === "partially_succeeded";
|
||
const peripheralRoute = String(result.orchestration_route || "")
|
||
.startsWith("nfc_peripheral");
|
||
summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`;
|
||
const presetSave = Boolean(result.preset_config);
|
||
const savedModeExecution = result.orchestration_route === "saved_mode_command_flow";
|
||
const modeSave = Boolean(result.mode_profile) && !savedModeExecution;
|
||
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||
const physical = report.spec.mode === "hil" && !simulatedHil;
|
||
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
|
||
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
|
||
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
|
||
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
|
||
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
|
||
const failedOperations = operationResults.filter((item) => item.status === "failed");
|
||
const submittedOperations = operationResults.filter(
|
||
(item) => operationVerificationStatus(item) === "submitted_unverified",
|
||
);
|
||
const physicallyConfirmedOperations = operationResults.filter(
|
||
(item) => item.telemetry?.physical_effect_confirmed === true,
|
||
);
|
||
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
|
||
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
|
||
let executionDetail = modeSave
|
||
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
|
||
: presetSave
|
||
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
|
||
: simulatedHil
|
||
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
|
||
: physical
|
||
? submittedUnverified
|
||
? `${executedDomains.join(" / ")} 已通过${result.control_transport === "wireless_mqtt" ? " MQTT" : "有线 CAN"}提交 ${submittedOperations.length || succeededOperations.length} 个白名单控制步骤;车辆效果尚未确认,以实时状态回读为准`
|
||
: result.control_transport === "wireless_mqtt"
|
||
? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${succeededOperations.length} 个白名单控制步骤,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`
|
||
: selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
|
||
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
|
||
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
|
||
if (partiallySucceeded) {
|
||
executionDetail = `部分执行完成:${succeededDomains.join(" / ")} 成功 ${succeededOperations.length} 项;${failedDomains.join(" / ")} 未完成 ${failedOperations.length} 项,失败设备未阻断其他设备`;
|
||
}
|
||
if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`;
|
||
if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件";
|
||
else if (firmwareResult) executionDetail += ";板上统一固件协议签名已校验";
|
||
if (result.deferred_requests?.length) {
|
||
executionDetail += ";未接入域请求已保留为 deferred request";
|
||
}
|
||
if (peripheralRoute && operationResults[0]?.message) {
|
||
executionDetail = result.sequence_summary || operationResults[0].message;
|
||
}
|
||
if (verificationStatus === "failed_or_blocked" && !partiallySucceeded) {
|
||
const failure = report.spec.results.find((item) => item.status === "failed");
|
||
const failureTelemetry = failure?.telemetry || {};
|
||
executionDetail = simulatedHil
|
||
? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。`
|
||
: failureTelemetry.error_type === "CanGatewayError"
|
||
? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}`
|
||
: failureTelemetry.error_type === "FirmwareProgrammingError"
|
||
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
|
||
: failureTelemetry.calibration_required
|
||
? `大幅相对移动已转换为绝对目标,但 Motor${failureTelemetry.motor_selector} 的 Position_Valid=0;未发送绝对移动命令,请先在台架清空并备好急停后执行显式端点标定。`
|
||
: failureTelemetry.failure_category === "stall_no_hall_activity"
|
||
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},未检测到持续 Hall 活动;可能已到目标方向机械端点/存在阻挡,或 Hall 供电、信号线未接通。请先安全地反向移动 2° 诊断。`
|
||
: failureTelemetry.failure_category === "stall_after_hall_activity"
|
||
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},运动后发生堵转,请检查目标方向端点或机械阻挡。`
|
||
: failureTelemetry.motor_result_name
|
||
? `Motor${failureTelemetry.motor_selector} ${failureTelemetry.motor_result_name},板端事件 ${failureTelemetry.last_motor_event_name || "UNAVAILABLE"};${failureTelemetry.stop_all_attempted ? "已尝试发送 STOP_ALL。" : "请检查硬件回读。"}`
|
||
: failureTelemetry.stop_all_attempted
|
||
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
|
||
: failure?.message || "真实台架未返回满足目标的物理反馈。";
|
||
}
|
||
const executionTitle = reportSucceeded
|
||
? peripheralRoute ? (submittedUnverified ? "外设指令已发布" : "外设动作已执行")
|
||
: modeSave ? "长期模式已保存"
|
||
: presetSave ? "姿态预设已保存"
|
||
: simulatedHil ? "模拟台架执行成功"
|
||
: physical
|
||
? submittedUnverified
|
||
? `${result.control_transport === "wireless_mqtt" ? "MQTT" : "CAN"} 指令已提交,等待状态确认`
|
||
: "真实台架执行并验证成功"
|
||
: "虚拟执行成功"
|
||
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
|
||
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
|
||
const executionIcon = submittedUnverified
|
||
? "circle-alert"
|
||
: reportSucceeded
|
||
? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big")
|
||
: partiallySucceeded ? "circle-alert" : "circle-x";
|
||
summary.innerHTML = `<i data-lucide="${executionIcon}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
|
||
$("#resultNavMeta").textContent = reportSucceeded
|
||
? peripheralRoute
|
||
? result.sequence
|
||
? `${result.peripheral?.name || "第三方外设"} · ${result.sequence.action_count} 项动作 / ${result.sequence.total_delay_ms / 1000} 秒`
|
||
: `${result.peripheral?.name || "第三方外设"} · IR SLOT ${result.peripheral?.slot ?? "--"}`
|
||
: submittedUnverified
|
||
? `${submittedOperations.length || succeededOperations.length} 项已提交 · 等待状态确认`
|
||
: `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
|
||
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
|
||
: "执行失败 · 查看回读";
|
||
$("#resultPanelState").textContent = submittedUnverified ? "SUBMITTED" : reportSucceeded ? "VERIFIED" : partiallySucceeded ? "PARTIAL" : "FAILED";
|
||
$("#resultPanelState").className = `workspace-panel-state ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`;
|
||
report.spec.results.forEach((item) => {
|
||
const row = document.createElement("div");
|
||
row.className = "result-item";
|
||
const itemVerification = operationVerificationStatus(item);
|
||
row.innerHTML = `<i data-lucide="${itemVerification === "verified_success" ? "circle-check" : itemVerification === "submitted_unverified" ? "circle-alert" : "circle-x"}"></i>`;
|
||
const text = document.createElement("div");
|
||
const title = document.createElement("strong");
|
||
title.textContent = item.ref;
|
||
const detail = document.createElement("span");
|
||
detail.textContent = item.message;
|
||
text.append(title, detail);
|
||
const frame = document.createElement("code");
|
||
const firmwareTelemetry = item.telemetry?.firmware_verified
|
||
? [
|
||
item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED",
|
||
item.telemetry.firmware_revision == null ? null : `REV ${item.telemetry.firmware_revision}`,
|
||
item.telemetry.identity_frame_id && item.telemetry.identity_data_hex
|
||
? `${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
|
||
: item.telemetry.protocol_signature,
|
||
].filter(Boolean).join(" · ")
|
||
: null;
|
||
frame.textContent = item.telemetry?.command_frame
|
||
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
|
||
: firmwareTelemetry
|
||
? firmwareTelemetry
|
||
: "CONTROL PLANE · NO CAN";
|
||
row.append(text, frame);
|
||
list.append(row);
|
||
});
|
||
refreshIcons();
|
||
}
|
||
|
||
function authoritativeCh1StatePatch(value) {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||
if (isAuthoritativeCh1Source(value.source || value.feedback_source)) {
|
||
return value;
|
||
}
|
||
const patch = {};
|
||
Object.entries(value).forEach(([key, item]) => {
|
||
const child = authoritativeCh1StatePatch(item);
|
||
if (child) patch[key] = child;
|
||
});
|
||
return Object.keys(patch).length ? patch : null;
|
||
}
|
||
|
||
function physicalSeatStatePatch(value) {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||
const source = value.source || value.feedback_source;
|
||
if (["physical_can", "physical_can_ch0", "wireless_mqtt_ch0", "physical_partial"].includes(source)) {
|
||
return value;
|
||
}
|
||
const patch = {};
|
||
Object.entries(value).forEach(([key, item]) => {
|
||
const child = physicalSeatStatePatch(item);
|
||
if (child) patch[key] = child;
|
||
});
|
||
return Object.keys(patch).length ? patch : null;
|
||
}
|
||
|
||
function isAuthoritativeVehicleStatePayload(payload) {
|
||
if (!payload || typeof payload !== "object") return false;
|
||
if (payload.conversation_id !== state.conversationId) return false;
|
||
return Boolean(
|
||
authoritativeCh1StatePatch(payload.actuator_state)
|
||
|| authoritativeCh1StatePatch(payload.domain_state),
|
||
);
|
||
}
|
||
|
||
function recordVehicleStateTelemetry(payload) {
|
||
if (!payload || payload.conversation_id !== state.conversationId) return;
|
||
const previous = state.vehicleStateSnapshot || {};
|
||
const source = payload.source === "wireless_mqtt_ch1"
|
||
|| payload.control_transport === "wireless_mqtt"
|
||
? "wireless_mqtt_ch1"
|
||
: "physical_can_ch1";
|
||
state.vehicleStateSnapshot = {
|
||
...previous,
|
||
error: null,
|
||
errorStatus: null,
|
||
errorCode: "",
|
||
errorReason: "",
|
||
source,
|
||
capturedAt: payload.captured_at || null,
|
||
receivedAt: Date.now(),
|
||
captureSequence: Number.isFinite(Number(payload.capture_sequence))
|
||
? Number(payload.capture_sequence)
|
||
: null,
|
||
observedFrameIds: Array.isArray(payload.observed_frame_ids)
|
||
? payload.observed_frame_ids
|
||
: [],
|
||
bodyStateComplete: payload.body_state_complete === true,
|
||
bodyStateMissing: Array.isArray(payload.body_state_missing)
|
||
? payload.body_state_missing
|
||
: [],
|
||
};
|
||
renderCabinLiveStatus();
|
||
}
|
||
|
||
function applyAuthoritativeVehicleState(payload) {
|
||
if (!payload || payload.conversation_id !== state.conversationId) return false;
|
||
const turn = Number(payload.conversation_turn);
|
||
if (Number.isFinite(turn) && turn < state.vehicleStateTurn) return false;
|
||
recordVehicleStateTelemetry(payload);
|
||
if (!isAuthoritativeVehicleStatePayload(payload)) return false;
|
||
if (Number.isFinite(turn)) state.vehicleStateTurn = turn;
|
||
const actuatorPatch = authoritativeCh1StatePatch(payload.actuator_state);
|
||
const domainPatch = authoritativeCh1StatePatch(payload.domain_state);
|
||
const previousActuatorState = state.vehicleStateSnapshot?.actuatorState || null;
|
||
state.vehicleStateSnapshot = {
|
||
...state.vehicleStateSnapshot,
|
||
conversationId: payload.conversation_id,
|
||
conversationTurn: Number.isFinite(turn) ? turn : null,
|
||
requestId: payload.request_id || null,
|
||
actuatorState: actuatorPatch
|
||
? mergeSeatActuatorState(previousActuatorState, actuatorPatch)
|
||
: previousActuatorState,
|
||
domainState: domainPatch,
|
||
};
|
||
if (actuatorPatch?.driver) {
|
||
const source = payload.control_transport === "wireless_mqtt"
|
||
|| payload.source === "wireless_mqtt_ch1"
|
||
? "wireless_mqtt_ch1"
|
||
: "physical_can_ch1";
|
||
state.seatFeedbackSource = source;
|
||
state.seatFeedbackSources.driver = source;
|
||
}
|
||
renderSeatState(actuatorPatch);
|
||
renderDomainState(domainPatch);
|
||
updateSeatFeedbackModeLabel();
|
||
return true;
|
||
}
|
||
|
||
window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
|
||
const payload = event.detail;
|
||
if (!payload || payload.conversation_id !== state.conversationId) return;
|
||
const appliedCh1 = applyAuthoritativeVehicleState(payload);
|
||
const passengerPatch = physicalSeatStatePatch(payload.actuator_state?.passenger);
|
||
if (passengerPatch) {
|
||
renderSeatState({ passenger: passengerPatch });
|
||
state.seatFeedbackSource = "physical_can";
|
||
state.seatFeedbackSources.passenger = "physical_can";
|
||
updateSeatFeedbackModeLabel();
|
||
}
|
||
if (!appliedCh1 && !passengerPatch && payload.execution_mode !== "hardware") {
|
||
renderSeatState(payload.actuator_state);
|
||
renderDomainState(payload.domain_state);
|
||
if (payload.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
|
||
if (payload.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
|
||
state.seatFeedbackSource = "simulated_hardware";
|
||
updateSeatFeedbackModeLabel();
|
||
}
|
||
});
|
||
|
||
function renderResult(result) {
|
||
state.result = result;
|
||
const reportMode = result.execution_report?.spec?.mode;
|
||
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
|
||
const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified";
|
||
if (feedbackSource === "simulated_hardware") {
|
||
state.seatFeedbackSource = "simulated_hardware";
|
||
if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
|
||
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
|
||
} else if (isAuthoritativeCh1Source(feedbackSource)
|
||
&& isAuthoritativeVehicleStatePayload(result)) {
|
||
state.seatFeedbackSource = feedbackSource;
|
||
state.seatFeedbackSources.driver = state.seatFeedbackSource;
|
||
} else if (reportMode === "hil") {
|
||
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
|
||
? "physical_can"
|
||
: "physical_partial";
|
||
state.seatFeedbackSources.passenger = state.seatFeedbackSource;
|
||
} else if (result.function_configs?.seat) {
|
||
state.seatFeedbackSource = "simulated";
|
||
if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated";
|
||
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated";
|
||
}
|
||
const partiallySucceeded = result.status === "partially_succeeded";
|
||
const executionSucceeded = result.status === "succeeded" || partiallySucceeded;
|
||
const savedModeExecution = result.orchestration_route === "saved_mode_command_flow";
|
||
const domains = Object.keys(result.function_configs || {});
|
||
const domainLabels = {
|
||
seat: "座椅",
|
||
climate: "空调",
|
||
roof: "天幕",
|
||
ambient_light: "氛围灯",
|
||
self_extension: "第三方外设",
|
||
};
|
||
const peripheralRoute = String(result.orchestration_route || "")
|
||
.startsWith("nfc_peripheral");
|
||
$("#resultTitle").textContent = peripheralRoute && executionSucceeded
|
||
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
|
||
: partiallySucceeded
|
||
? `${domains.length} 域场景部分完成`
|
||
: executionSucceeded
|
||
? result.mode_profile
|
||
? savedModeExecution
|
||
? `${result.mode_profile.name}执行完成`
|
||
: "长期模式配置已保存"
|
||
: result.preset_config
|
||
? "座椅姿态预设已保存"
|
||
: submittedUnverified
|
||
? `${domains.length > 1 ? `${domains.length} 域场景` : domainLabels[domains[0]] || "车舱"}控制指令已提交`
|
||
: result.scene_config
|
||
? `${domains.length} 域场景闭环完成`
|
||
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
|
||
: "请求未执行";
|
||
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
|
||
$("#requestMeta").textContent = `${result.request_id} / context:${conversationLabel} / turn:${result.conversation_turn} / ${result.provider_used || result.provider_requested}`;
|
||
$("#requestMeta").textContent += ` / ${result.execution_report?.spec?.mode || result.execution_mode || "dry_run"}`;
|
||
if (result.reasoning_effort_used) {
|
||
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
|
||
}
|
||
setRunStatus(
|
||
partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed",
|
||
submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(),
|
||
);
|
||
renderDecision(result);
|
||
renderTrace(result.trace_events || []);
|
||
renderValidation(result.validation || []);
|
||
const ch1HardwareResult = reportMode === "hil"
|
||
&& selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only";
|
||
if (ch1HardwareResult) {
|
||
applyAuthoritativeVehicleState(result);
|
||
} else {
|
||
renderSeatState(result.actuator_state);
|
||
renderDomainState(result.domain_state);
|
||
updateSeatFeedbackModeLabel();
|
||
}
|
||
updateTwinExplicitAngles(result);
|
||
renderSavedPresets(result);
|
||
refreshModeProfileFromResult(result);
|
||
renderCode(result);
|
||
renderExecution(result);
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
|
||
}
|
||
|
||
async function postControlPhase(path, body, executionMode, onEvent = null) {
|
||
const streaming = typeof onEvent === "function";
|
||
const response = await apiRequest(path, {
|
||
method: "POST",
|
||
timeoutMs: requestTimeoutForExecutionMode(executionMode),
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
...(streaming ? { Accept: "application/x-ndjson" } : {}),
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return null;
|
||
}
|
||
if (streaming && response.ok && response.body?.getReader) {
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffered = "";
|
||
let resultPayload = null;
|
||
const handleLine = (line) => {
|
||
if (!line.trim()) return;
|
||
const phaseEvent = JSON.parse(line);
|
||
if (phaseEvent.type === "error") {
|
||
const requestError = new Error(
|
||
phaseEvent.message || phaseEvent.error || "流式控制请求失败",
|
||
);
|
||
requestError.code = phaseEvent.error;
|
||
requestError.outcome = phaseEvent.outcome;
|
||
requestError.detail = phaseEvent.detail;
|
||
requestError.retryAfter = Number(phaseEvent.retry_after_seconds || 0);
|
||
throw requestError;
|
||
}
|
||
if (phaseEvent.type === "result") {
|
||
resultPayload = phaseEvent.payload;
|
||
} else {
|
||
onEvent(phaseEvent);
|
||
}
|
||
};
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||
const lines = buffered.split("\n");
|
||
buffered = lines.pop() || "";
|
||
lines.forEach(handleLine);
|
||
if (done) break;
|
||
}
|
||
if (buffered.trim()) handleLine(buffered);
|
||
if (!resultPayload) throw new Error("流式控制请求未返回完整结果");
|
||
return resultPayload;
|
||
}
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
|
||
requestError.code = payload.error;
|
||
requestError.retryAfter = Number(
|
||
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
|
||
);
|
||
throw requestError;
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function naturalLanguageRequestBody(
|
||
utterance,
|
||
{
|
||
executionMode = state.executionMode,
|
||
clientType = "web_console",
|
||
provider = state.provider,
|
||
speechUtteranceId = null,
|
||
} = {},
|
||
) {
|
||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||
return {
|
||
utterance,
|
||
client_type: clientType,
|
||
provider,
|
||
conversation_id: state.conversationId,
|
||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||
execution_mode: executionMode,
|
||
control_transport: state.controlTransport,
|
||
...(provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
|
||
...(speechUtteranceId ? { speech_utterance_id: speechUtteranceId } : {}),
|
||
...(executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
? { hardware_client_id: state.hardwareClientId }
|
||
: {}),
|
||
...(executionMode === "simulated_hardware"
|
||
? { simulation_fault: $("#simulationFault").value }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
async function requestNaturalLanguageControl(
|
||
utterance,
|
||
{
|
||
executionMode = state.executionMode,
|
||
clientType = "web_console",
|
||
provider = state.provider,
|
||
speechUtteranceId = null,
|
||
previewOnly = false,
|
||
onControlPhaseEvent = null,
|
||
onPreview = null,
|
||
onSupplementStart = null,
|
||
} = {},
|
||
) {
|
||
const requestBody = naturalLanguageRequestBody(utterance, {
|
||
executionMode,
|
||
clientType,
|
||
provider,
|
||
speechUtteranceId,
|
||
});
|
||
const usePreview = previewOnly || provider === "openai";
|
||
if (!usePreview) {
|
||
const payload = await postControlPhase(
|
||
"/api/requests",
|
||
requestBody,
|
||
executionMode,
|
||
onControlPhaseEvent,
|
||
);
|
||
return { payload, supplementWarning: "" };
|
||
}
|
||
|
||
const preview = await postControlPhase(
|
||
"/api/requests/preview",
|
||
requestBody,
|
||
executionMode,
|
||
onControlPhaseEvent,
|
||
);
|
||
if (!preview) return { payload: null, supplementWarning: "" };
|
||
if (typeof onPreview === "function") onPreview(preview);
|
||
if (!preview.supplement_required) {
|
||
return { payload: preview, supplementWarning: "" };
|
||
}
|
||
|
||
if (typeof onSupplementStart === "function") onSupplementStart(preview);
|
||
try {
|
||
const payload = await postControlPhase(
|
||
`/api/requests/${preview.request_id}/supplement`,
|
||
{
|
||
client_type: clientType,
|
||
provider,
|
||
reasoning_effort: state.reasoningEffort,
|
||
...(executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !isDriverOnlyCh1Runtime()
|
||
? { hardware_client_id: state.hardwareClientId }
|
||
: {}),
|
||
},
|
||
executionMode,
|
||
onControlPhaseEvent,
|
||
);
|
||
return { payload, supplementWarning: "" };
|
||
} catch (error) {
|
||
const localCommandCount = Object.values(preview.function_configs || {})
|
||
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
|
||
if (!localCommandCount) throw error;
|
||
return {
|
||
payload: {
|
||
...preview,
|
||
status: "partially_succeeded",
|
||
supplement_required: false,
|
||
cloud_supplement_error: {
|
||
code: error.code || "cloud_supplement_failed",
|
||
message: error.message,
|
||
},
|
||
decision_summary: [
|
||
...(preview.decision_summary || []),
|
||
`云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
|
||
],
|
||
},
|
||
supplementWarning: previewOnly
|
||
? "本地规则指令已生成;云端补充失败"
|
||
: "本地规则命令已执行;云端补充失败,已保留本地结果",
|
||
};
|
||
}
|
||
}
|
||
|
||
async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) {
|
||
const commands = Array.isArray(canCommands)
|
||
? canCommands.map((command) => String(command || "").trim()).filter(Boolean)
|
||
: [];
|
||
if (!commands.length) throw new Error("没有可发送的 CAN 白名单控制指令");
|
||
if (state.directCanCommandPending) throw new Error("上一组 CAN 控制指令仍在执行,请稍候");
|
||
|
||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||
if (state.executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
&& !isLocalDecisionHost()
|
||
&& state.hardwareAccess?.state !== "approved") {
|
||
throw new Error(state.hardwareAccess?.state === "pending"
|
||
? "申请仍在等待本地审批"
|
||
: "请先申请并获得本地真实台架使用权");
|
||
}
|
||
|
||
state.directCanCommandPending = true;
|
||
try {
|
||
const payload = await postControlPhase(
|
||
"/api/can/commands",
|
||
{
|
||
commands,
|
||
client_type: "web_console_device_control",
|
||
conversation_id: state.conversationId,
|
||
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
|
||
execution_mode: state.executionMode,
|
||
control_transport: state.controlTransport,
|
||
...(state.executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
? { hardware_client_id: state.hardwareClientId }
|
||
: {}),
|
||
...(state.executionMode === "simulated_hardware"
|
||
? { simulation_fault: $("#simulationFault").value }
|
||
: {}),
|
||
},
|
||
state.executionMode,
|
||
);
|
||
if (!payload) throw new Error("CAN 控制请求未返回结果");
|
||
renderResult(payload);
|
||
if (state.executionMode === "hardware") await synchronizePhysicalSeatState();
|
||
showToast(`${deviceName} CAN 控制已发送`);
|
||
return payload;
|
||
} finally {
|
||
state.directCanCommandPending = false;
|
||
}
|
||
}
|
||
|
||
async function submitRequest(event) {
|
||
event.preventDefault();
|
||
const utteranceInput = $("#utterance");
|
||
const utterance = utteranceInput.value.trim();
|
||
const speechSubmission = (
|
||
utteranceInput.dataset.speechExecutionEligible === "true"
|
||
&& utteranceInput.dataset.speechValue === utterance
|
||
&& Boolean(utteranceInput.dataset.speechUtteranceId)
|
||
);
|
||
const requestProvider = speechSubmission ? "local" : state.provider;
|
||
const submittedModeProfileId = state.pendingModeProfileId;
|
||
state.pendingModeProfileId = null;
|
||
if (!utterance) {
|
||
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
|
||
showToast("请输入车舱控制请求");
|
||
return;
|
||
}
|
||
if (
|
||
utteranceInput.dataset.speechExecutionEligible === "false"
|
||
&& utteranceInput.dataset.speechValue === utterance
|
||
) {
|
||
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
|
||
const speechStatus = utteranceInput.dataset.speechSemanticStatus;
|
||
const message = speechStatus === "recognized_unsupported"
|
||
? "语音意图已识别,但当前车辆能力不支持执行"
|
||
: speechStatus === "incomplete"
|
||
? "语音控制参数不完整,请补充后再执行"
|
||
: "语音结果存在歧义,请修改确认后再执行";
|
||
showToast(message);
|
||
return;
|
||
}
|
||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||
if (state.executionMode === "hardware"
|
||
&& state.controlTransport !== "wireless_mqtt"
|
||
&& !driverOnlyCh1
|
||
&& !isLocalDecisionHost()
|
||
&& state.hardwareAccess?.state !== "approved") {
|
||
const accessMessage = state.hardwareAccess?.state === "pending"
|
||
? "申请仍在等待本地审批"
|
||
: "请先申请并获得本地真实台架使用权";
|
||
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
|
||
showToast(accessMessage);
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
|
||
detail: { utterance, result: null, error: new Error(accessMessage) },
|
||
}));
|
||
return;
|
||
}
|
||
const button = $("#runButton");
|
||
let completedResult = null;
|
||
let completedError = null;
|
||
button.disabled = true;
|
||
setModeChipsDisabled(true);
|
||
button.classList.add("loading");
|
||
button.innerHTML = '<i data-lucide="loader-circle"></i><span>处理中</span>';
|
||
refreshIcons();
|
||
setRunStatus("processing", "PROCESSING");
|
||
$("#resultTitle").textContent = "正在执行车舱编排";
|
||
try {
|
||
const onControlPhaseEvent = (phaseEvent) => {
|
||
if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
|
||
window.dispatchEvent(new CustomEvent(
|
||
"cognitive-agent:validated-can-commands",
|
||
{ detail: phaseEvent.payload },
|
||
));
|
||
};
|
||
const { payload, supplementWarning } = await requestNaturalLanguageControl(
|
||
utterance,
|
||
{
|
||
provider: requestProvider,
|
||
speechUtteranceId: speechSubmission
|
||
? utteranceInput.dataset.speechUtteranceId
|
||
: null,
|
||
onControlPhaseEvent,
|
||
onPreview: (preview) => {
|
||
renderResult(preview);
|
||
completedResult = preview;
|
||
},
|
||
onSupplementStart: () => {
|
||
setRunStatus("processing", "CLOUD SUPPLEMENT");
|
||
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
|
||
},
|
||
},
|
||
);
|
||
if (!payload) return;
|
||
renderResult(payload);
|
||
completedResult = payload;
|
||
if (speechSubmission) {
|
||
delete utteranceInput.dataset.speechUtteranceId;
|
||
delete utteranceInput.dataset.speechExecutionEligible;
|
||
delete utteranceInput.dataset.speechSemanticStatus;
|
||
delete utteranceInput.dataset.speechValue;
|
||
delete utteranceInput.dataset.speechUncertain;
|
||
}
|
||
const peripheralRoute = String(payload.orchestration_route || "")
|
||
.startsWith("nfc_peripheral");
|
||
if (state.executionMode === "hardware" && !peripheralRoute) {
|
||
await synchronizePhysicalSeatState();
|
||
}
|
||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||
if (submittedModeProfileId) {
|
||
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
|
||
}
|
||
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
|
||
const submittedUnverified = executionVerificationStatus(payload) === "submitted_unverified";
|
||
showToast(supplementWarning || (completed
|
||
? (payload.mode_profile
|
||
? `${payload.mode_profile.name}已创建并添加到功能模式`
|
||
: peripheralRoute
|
||
? `${payload.peripheral?.name || "第三方外设"} · ${payload.peripheral?.action || "动作"}指令已发送`
|
||
: payload.status === "partially_succeeded"
|
||
? "已执行可用设备;部分设备未完成,详见执行结果"
|
||
: simulated
|
||
? "模拟台架执行完成"
|
||
: submittedUnverified
|
||
? "CAN 控制指令已提交;等待车辆实时状态确认"
|
||
: payload.execution_report?.spec?.mode === "hil"
|
||
? "真实台架执行与反馈确认已完成"
|
||
: "可执行动作的 dry-run 已完成")
|
||
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行"));
|
||
} catch (error) {
|
||
completedError = error;
|
||
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
|
||
if (error.code === "hardware_access_required") {
|
||
setRunStatus("failed", "ACCESS REQUIRED");
|
||
$("#resultTitle").textContent = "真实台架使用权无效";
|
||
await loadHardwareAccessStatus({ silent: true });
|
||
showToast(error.message);
|
||
} else if (error.code === "hardware_busy") {
|
||
setRunStatus("failed", "BENCH BUSY");
|
||
$("#resultTitle").textContent = "真实台架正在使用";
|
||
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
|
||
showToast(`${error.message}${retryHint}`);
|
||
} else {
|
||
setRunStatus("failed", "FAILED");
|
||
$("#resultTitle").textContent = "请求处理失败";
|
||
showToast(error.message);
|
||
}
|
||
} finally {
|
||
button.disabled = false;
|
||
setModeChipsDisabled(false);
|
||
button.classList.remove("loading");
|
||
const buttonLabel = state.executionMode === "hardware"
|
||
? "真实执行"
|
||
: state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行";
|
||
button.innerHTML = `<i data-lucide="play"></i><span>${buttonLabel}</span>`;
|
||
refreshIcons();
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
|
||
detail: { utterance, result: completedResult, error: completedError },
|
||
}));
|
||
}
|
||
}
|
||
|
||
function activateTab(tabName) {
|
||
$$(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === tabName));
|
||
const panel = $(`#panel-${tabName}`);
|
||
if (!panel) return;
|
||
panel.classList.add("navigation-focus");
|
||
panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
window.setTimeout(() => panel.classList.remove("navigation-focus"), 900);
|
||
}
|
||
|
||
function initResultWorkspace() {
|
||
const panels = $$(".result-workspace .tab-panel");
|
||
panels.forEach((panel) => {
|
||
panel.hidden = false;
|
||
panel.classList.add("active");
|
||
});
|
||
if (!("IntersectionObserver" in window)) return;
|
||
const observer = new IntersectionObserver((entries) => {
|
||
const visible = entries
|
||
.filter((entry) => entry.isIntersecting)
|
||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||
if (!visible) return;
|
||
const tabName = visible.target.id.replace("panel-", "");
|
||
$$(".result-nav .tab").forEach((tab) => {
|
||
tab.classList.toggle("active", tab.dataset.tab === tabName);
|
||
});
|
||
}, { rootMargin: "-18% 0px -56% 0px", threshold: [0.08, 0.3, 0.6] });
|
||
panels.forEach((panel) => observer.observe(panel));
|
||
}
|
||
|
||
async function copyArtifact(type) {
|
||
const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {});
|
||
if (!value) return showToast("当前没有可复制的内容");
|
||
await navigator.clipboard.writeText(toYaml(value));
|
||
showToast("已复制到剪贴板");
|
||
}
|
||
|
||
function downloadArtifact(type) {
|
||
const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {});
|
||
if (!value) return showToast("当前没有可下载的内容");
|
||
const blob = new Blob([toYaml(value)], { type: "text/yaml;charset=utf-8" });
|
||
const link = document.createElement("a");
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = type === "config"
|
||
? (state.result?.preset_config
|
||
? "seat-preset.yaml"
|
||
: state.result?.scene_config
|
||
? "cabin-scene-bundle.yaml"
|
||
: "function-config.yaml")
|
||
: "command-plans.yaml";
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
}
|
||
|
||
function startNewConversation(event) {
|
||
state.conversationId = String(event?.detail?.conversationId || createConversationId());
|
||
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
|
||
}
|
||
|
||
window.CognitiveAgentConsole = {
|
||
executeUtterance(utterance, { autoSubmit = true } = {}) {
|
||
const input = $("#utterance");
|
||
const button = $("#runButton");
|
||
if (!input || !button || button.disabled) return false;
|
||
input.value = String(utterance || "").trim();
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.focus({ preventScroll: true });
|
||
if (autoSubmit) $("#requestForm").requestSubmit();
|
||
return true;
|
||
},
|
||
executeCanCommands(canCommands, options = {}) {
|
||
return executeCanCommands(canCommands, options);
|
||
},
|
||
getExecutionMode() {
|
||
return state.executionMode;
|
||
},
|
||
getConversationId() {
|
||
return state.conversationId;
|
||
},
|
||
getResult() {
|
||
return state.result;
|
||
},
|
||
applySyncedResult(result, { utterance = "" } = {}) {
|
||
if (!result || typeof result !== "object") return false;
|
||
if (result.conversation_id) {
|
||
state.conversationId = String(result.conversation_id);
|
||
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
|
||
}
|
||
const input = $("#utterance");
|
||
if (input && utterance && document.activeElement !== input
|
||
&& !document.body.classList.contains("assistant-chat-enabled")) input.value = String(utterance);
|
||
const hasFullExecutionResult = Boolean(
|
||
result.execution_report
|
||
|| result.function_configs
|
||
|| result.scene_config
|
||
|| result.validation,
|
||
);
|
||
if (!hasFullExecutionResult) {
|
||
refreshModeProfileFromResult(result);
|
||
return true;
|
||
}
|
||
renderResult(result);
|
||
return true;
|
||
},
|
||
async refreshModeProfiles(modeProfileId = "") {
|
||
await loadRuntime();
|
||
if (!modeProfileId) return true;
|
||
return revealModeProfile(String(modeProfileId), { focus: true });
|
||
},
|
||
async tryModeDraft(draft) {
|
||
const instruction = typeof draft === "string"
|
||
? draft.trim()
|
||
: String(draft?.instruction || "").trim();
|
||
if (!instruction) throw new Error("功能模式草稿缺少可试运行的指令。");
|
||
let payload;
|
||
if (draft && typeof draft === "object" && draft.source_request_id) {
|
||
const response = await apiRequest("/api/mode-profile-drafts/trial", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: draft.name,
|
||
instruction,
|
||
source_request_id: draft.source_request_id,
|
||
vehicle_id: draft.vehicle_id || state.vehicleId || null,
|
||
}),
|
||
timeoutMs: 30 * 1000,
|
||
retries: 0,
|
||
});
|
||
payload = await response.json();
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return null;
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(payload.message || payload.error || `HTTP ${response.status}`);
|
||
}
|
||
} else {
|
||
({ payload } = await requestNaturalLanguageControl(instruction, {
|
||
executionMode: "dry_run",
|
||
clientType: "assistant_mode_trial",
|
||
provider: "local",
|
||
previewOnly: false,
|
||
}));
|
||
}
|
||
if (!payload) throw new Error("模拟试运行没有返回结果。");
|
||
const actualOperationCount = Object.values(payload.function_configs || {})
|
||
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
|
||
const expectedOperationCount = Number(
|
||
typeof draft === "object" ? draft?.operation_count : 0,
|
||
);
|
||
const actionSignature = (action) => {
|
||
const target = action?.target && typeof action.target === "object"
|
||
? action.target.instance || action.target.zone || action.target.device || ""
|
||
: action?.target || "";
|
||
const parameters = action?.parameters && typeof action.parameters === "object"
|
||
? Object.fromEntries(Object.entries(action.parameters).sort(([left], [right]) => left.localeCompare(right)))
|
||
: {};
|
||
return JSON.stringify([
|
||
String(action?.domain || ""),
|
||
String(action?.capability || ""),
|
||
String(target),
|
||
parameters,
|
||
]);
|
||
};
|
||
const expectedSignatures = Array.isArray(draft?.actions)
|
||
? draft.actions.map(actionSignature).sort()
|
||
: [];
|
||
const actualActions = Array.isArray(payload?.intent_ir?.spec?.actions)
|
||
? payload.intent_ir.spec.actions
|
||
: [];
|
||
const actualSignatures = actualActions.map(actionSignature).sort();
|
||
const sameActions = expectedSignatures.length === 0
|
||
|| (expectedSignatures.length === actualSignatures.length
|
||
&& expectedSignatures.every((signature, index) => signature === actualSignatures[index]));
|
||
if (payload.status !== "succeeded"
|
||
|| (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount)
|
||
|| !sameActions) {
|
||
throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。");
|
||
}
|
||
return payload;
|
||
},
|
||
revealModeProfile,
|
||
notify(message) {
|
||
showToast(String(message));
|
||
},
|
||
logout,
|
||
};
|
||
|
||
window.addEventListener("cognitive-agent:connection", (event) => {
|
||
const previousStatus = state.connectionStatus;
|
||
state.connectionStatus = event.detail?.status || "offline";
|
||
if (["offline", "degraded"].includes(previousStatus)
|
||
&& state.connectionStatus === "online") {
|
||
Promise.all([loadAuthStatus(), loadRuntime()])
|
||
.then(() => showToast("服务连接已恢复"))
|
||
.catch(() => {});
|
||
}
|
||
});
|
||
|
||
window.addEventListener("cognitive-agent:request-complete", (event) => {
|
||
const modeProfileId = state.pendingModeProfileId;
|
||
if (!modeProfileId) return;
|
||
state.pendingModeProfileId = null;
|
||
const result = event.detail?.result;
|
||
const succeeded = !event.detail?.error
|
||
&& ["succeeded", "partially_succeeded"].includes(String(result?.status || ""));
|
||
setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed");
|
||
});
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
initGlassThemeToggle();
|
||
initJointTwinLayout();
|
||
resetVehicleStatusDisplay();
|
||
initResultWorkspace();
|
||
refreshIcons();
|
||
window.requestAnimationFrame(initSeatTwin);
|
||
$("#requestForm").addEventListener("submit", submitRequest);
|
||
$("#utterance").addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||
event.preventDefault();
|
||
$("#requestForm").requestSubmit();
|
||
}
|
||
});
|
||
window.addEventListener("cognitive-agent:new-conversation", startNewConversation);
|
||
$("#vehicleSelect").addEventListener("change", (event) => {
|
||
selectVehicle(event.target.value);
|
||
});
|
||
installModeChipScroller();
|
||
installModeActionPreview();
|
||
$(".mode-chips").addEventListener("click", (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
const runControl = target?.closest("[data-mode-run]");
|
||
const button = (runControl || target)?.closest("button[data-mode-command]");
|
||
if (button) applyModeCommand(
|
||
button.dataset.modeCommand,
|
||
button.dataset.modeProfileId || null,
|
||
);
|
||
});
|
||
$("#modeRailToggle").addEventListener("click", () => {
|
||
setModeRailExpanded($("#modeRailToggle").getAttribute("aria-expanded") !== "true");
|
||
});
|
||
$("#modeGalleryToggle").addEventListener("click", openModeGallery);
|
||
$("#modeGalleryClose").addEventListener("click", () => closeModeGallery());
|
||
$("#modeGalleryBackdrop").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) closeModeGallery();
|
||
});
|
||
$("#modeGalleryGroups").addEventListener("click", (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
const createButton = target?.closest("button[data-mode-create]");
|
||
if (createButton) {
|
||
event.preventDefault();
|
||
startAssistantModeCreation({ restoreSelector: "[data-mode-create]" });
|
||
return;
|
||
}
|
||
const editButton = target?.closest("button[data-mode-edit]");
|
||
if (editButton) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openModeEditor(editButton.dataset.modeEdit);
|
||
return;
|
||
}
|
||
const deleteButton = target?.closest("button[data-mode-delete]");
|
||
if (deleteButton) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
deleteUserModeProfile(deleteButton.dataset.modeDelete);
|
||
return;
|
||
}
|
||
const button = target?.closest("button[data-mode-command]");
|
||
if (button) applyModeCommand(
|
||
button.dataset.modeCommand,
|
||
button.dataset.modeProfileId || null,
|
||
);
|
||
});
|
||
$("#modeEditorClose").addEventListener("click", () => closeModeEditor());
|
||
$("#modeEditorCancel").addEventListener("click", () => closeModeEditor());
|
||
$("#modeEditorExecute").addEventListener("click", executeModeEditorInstruction);
|
||
$("#modeEditorComplete").addEventListener("click", completeModeEditor);
|
||
$("#modeEditorFlowExpand").addEventListener("click", toggleModeEditorFlowExpanded);
|
||
$("#modeEditorInstruction").addEventListener("input", invalidateModeEditorFlow);
|
||
$("#modeEditorBackdrop").addEventListener("click", (event) => {
|
||
if (event.target === event.currentTarget) closeModeEditor();
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && !$("#modeEditorBackdrop").hidden) {
|
||
event.preventDefault();
|
||
if ($("#modeEditorFlowExpand").getAttribute("aria-expanded") === "true") {
|
||
setModeEditorFlowExpanded(false);
|
||
} else {
|
||
closeModeEditor();
|
||
}
|
||
} else if (event.key === "Escape" && !$("#modeGalleryBackdrop").hidden) {
|
||
event.preventDefault();
|
||
closeModeGallery();
|
||
} else if (event.key === "Escape" && $("#modeRailToggle").getAttribute("aria-expanded") === "true") {
|
||
event.preventDefault();
|
||
setModeRailExpanded(false);
|
||
$("#modeRailToggle").focus();
|
||
}
|
||
});
|
||
$("#saveModeToggle").addEventListener("click", () => startAssistantModeCreation());
|
||
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
|
||
$$('[data-reasoning-effort]').forEach((button) => button.addEventListener("click", () => selectReasoningEffort(button.dataset.reasoningEffort)));
|
||
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
|
||
$$('[data-control-transport]').forEach((button) => button.addEventListener("click", async () => {
|
||
selectControlTransport(button.dataset.controlTransport);
|
||
await loadRuntime();
|
||
restartPhysicalStatePolling();
|
||
await synchronizePhysicalSeatState();
|
||
}));
|
||
$$(".tab").forEach((tab) => tab.addEventListener("click", () => activateTab(tab.dataset.tab)));
|
||
$$('[data-copy]').forEach((button) => button.addEventListener("click", () => copyArtifact(button.dataset.copy)));
|
||
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
||
await loadAuthStatus();
|
||
await loadRuntime();
|
||
if (!isDriverOnlyCh1Runtime()) {
|
||
await loadHardwareAccessStatus({ silent: true });
|
||
state.hardwareAccessPoller = window.setInterval(
|
||
() => loadHardwareAccessStatus({ silent: true }),
|
||
3000,
|
||
);
|
||
}
|
||
await synchronizePhysicalSeatState();
|
||
restartPhysicalStatePolling();
|
||
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
|
||
$("#requestForm").requestSubmit();
|
||
}
|
||
});
|