Files
Cognitive_Agent/apps/web-console/app.js
T
minghanchen fd13d9f1ee Merge branch 'feature/N7Web' of https://git.kendeji.fun/vee/Cognitive_Agent into feature/N7Web
# Conflicts:
#	apps/web-console/app.js
#	apps/web-console/index.html
#	apps/web-console/service-worker.js
#	src/cognitive_agent/server.py
#	tests/e2e/test_seat_web_api.py
2026-08-27 14:43:44 +08:00

7952 lines
311 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
pendingModeProfileId: null,
modeRunTimer: null,
modeDraftCommand: "",
lastSuccessfulUtterance: "",
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"]);
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;
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;
}
const snapshot = state.vehicleStateSnapshot;
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 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: 0x086861,
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(0x258f89);
roofGlassMaterial.emissive.setHex(0x124c49);
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: 0x086861,
emissive: 0x124c49,
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,
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(0x74d9ff),
ambientIdle: new THREE.Color(0x17323a),
climateTarget: new THREE.Color(0x55bdec),
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 = {
user: {
accent: 0x0e7773,
accentStrong: 0x095d5a,
accentSoft: 0xd8f4f1,
sceneBackground: 0xe8f2ef,
floor: 0xdceae6,
gridStrong: 0x69a49e,
gridSoft: 0xb9cfca,
fillLight: 0x9fd8cf,
vehicleOutline: 0x0e7773,
vehicleOccluded: 0x91b9b4,
seatShell: 0x0e7773,
seatInsert: 0xbfe9e4,
seatThermal: 0x79ccc4,
seatBolster: 0x095d5a,
seatStructure: 0x183f3d,
seatEdge: 0x62d2c8,
seatSeam: 0xd8f4f1,
glass: 0x258f89,
glassEmissive: 0x124c49,
glassEdge: 0x086861,
glassGlow: 0x49e1d6,
roofClear: 0x4aa9a3,
roofDark: 0x0a5552,
lamp: 0x087b74,
lampEmissive: 0x16aaa1,
lampEdge: 0x096d67,
},
};
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 accent = new THREE.Color(palette.accent);
const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52);
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;
// The white base is fully opaque beneath the vehicle and fades to completely
// transparent at the outer edge. Theme colors are reserved for the grid.
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(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.18, "rgba(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.48, "rgba(255, 255, 255, 0.72)");
surfaceGradient.addColorStop(0.75, "rgba(255, 255, 255, 0.28)");
surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 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(gridColor, major ? 0.32 : 0.16);
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) {
if (!systems) return;
const palette = TWIN_THEME_PALETTES.user;
systems.themeRole = "user";
systems.themePalette = palette;
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 applyTwinSceneTheme(twin) {
if (!twin) return;
const palette = TWIN_THEME_PALETTES.user;
applyTwinVehicleTheme(twin.vehicleSystems);
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));
[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);
const canvas = twin.renderer?.domElement;
if (canvas) {
canvas.dataset.themeRole = "user";
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 || TWIN_THEME_PALETTES.user;
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 || TWIN_THEME_PALETTES.user;
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;
});
});
twin.view.targetX = focusedSeat === "driver" ? 0.34 : -0.34;
twin.view.yaw = focusedSeat === "driver" ? -0.48 : -0.72;
renderTwinReadout();
}
function renderTwinCalloutValues(device) {
const value = (axis, fallback) => Number(device?.position?.[axis]?.position_pct ?? fallback);
const angle = (axis, fallbackPosition) => Number(
device?.position?.[axis]?.angle_deg
?? twinAngleForPosition(axis, value(axis, fallbackPosition)),
);
const longitudinal = value("longitudinal", 50);
const cushion = value("cushion_tilt", 50);
const height = value("height", 50);
const backrest = value("backrest_recline", 20);
const legrest = value("legrest_height", 0);
const extension = value("legrest_extension", 0);
const postureAngles = twinPostureAngles(device);
const explicitAngles = state.seatTwin?.explicitAngleTargets[
state.seatTwin.selected
] || {};
const backrestEstimated = isEstimatedMotion(device, "backrest_recline");
const legrestEstimated = isEstimatedMotion(device, "legrest_height");
const formatAngle = (number) => (
Number.isInteger(number) ? String(number) : number.toFixed(1)
);
const values = {
longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "前移区" : longitudinal > 55 ? "后移区" : "中位区"],
cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`],
height: [`${height}%`, "CH1 高度电机"],
backrest_recline: [
`${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`,
`${backrestEstimated ? "估算 " : ""}${backrest}% · 相对归零位`,
],
legrest_height: [
`${legrestEstimated ? "≈" : ""}${formatAngle(postureAngles.legrest_height)}°`,
`${legrestEstimated ? "估算 " : ""}${legrest}% · 相对垂直零位`,
],
legrest_extension: [`${extension}%`, "伸缩行程"],
backrest_included_angle: [
`${backrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.backrest_recline ?? angle("backrest_recline", 20))}°`,
backrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
legrest_included_angle: [
`${legrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.legrest_height ?? angle("legrest_height", 0))}°`,
legrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
};
Object.entries(values).forEach(([axis, [primary, secondary]]) => {
const callout = $(`[data-callout="${axis}"]`);
$("b", callout).textContent = primary;
$("small", callout).textContent = secondary;
});
}
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));
});
$$('[data-callout]').forEach((callout) => {
const axis = callout.dataset.callout;
if (axis?.endsWith("included_angle")) return;
callout.hidden = !supportedAxes.has(axis);
});
["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}档` : "关闭";
});
renderTwinCalloutValues(device);
}
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: 0x315d69,
lineOpacity: 0.48,
occludedColor: 0xa7b8bb,
occludedLineOpacity: 0.1,
fillOpacity: 0.11,
thresholdAngle: 38,
renderOrder: 30,
},
glass: {
color: 0x527c85,
lineOpacity: 0.38,
occludedColor: 0xa5b8bb,
occludedLineOpacity: 0.06,
fillOpacity: 0.045,
thresholdAngle: 38,
renderOrder: 31,
},
detail: {
color: 0x536f76,
lineOpacity: 0.16,
occludedColor: 0xa8b7b9,
occludedLineOpacity: 0.035,
fillOpacity: 0.028,
thresholdAngle: 46,
renderOrder: 32,
},
wheel: {
color: 0x203d45,
lineOpacity: 0.52,
occludedColor: 0x9eafb2,
occludedLineOpacity: 0.14,
fillOpacity: 0.18,
thresholdAngle: 50,
renderOrder: 33,
},
interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, 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 configureReferenceVehicleModel(THREE, vehicle) {
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);
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") {
if (material.color) material.color.setHex(0x55727b);
material.transparent = true;
material.opacity = 0.3;
material.depthWrite = false;
material.side = THREE.DoubleSide;
object.castShadow = false;
object.renderOrder = 12;
} else if (category === "body") {
if (material.color) material.color.setHex(0x829398);
if ("shininess" in material) material.shininess = 72;
} else if (category === "light") {
material.map = null;
if (material.color) material.color.setHex(0x697b80);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
} else if (category === "interior" && material.color) {
material.color.setHex(0x6c7778);
} else if (/lines|\badd\b/.test(descriptor) && material.color) {
material.map = null;
material.color.setHex(0x4c5a5d);
} else if (/map[_ -]?[abc]/.test(descriptor) && material.color) {
if (/chrome|metal/.test(descriptor)) material.color.setHex(0xaeb9bb);
else if (/plastic/.test(descriptor)) material.color.setHex(0x303a3e);
else material.color.setHex(0x7f8e91);
} else if (/plastic|under|black/.test(descriptor) && material.color) {
material.color.setHex(0x303a3e);
} else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) {
material.color.setHex(0xaeb9bb);
}
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.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(0x258f89);
if (material.emissive) material.emissive.setHex(0x124c49);
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: 0x086861,
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(0x087b74);
if (material.emissive) material.emissive.setHex(0x16aaa1);
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: 0x087b74,
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: 0x003f3b,
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: 0x49e1d6,
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: 0x1b8f88,
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: 0x096d67,
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();
scene.background = new THREE.Color(0xe8eeec);
scene.fog = new THREE.Fog(0xe8eeec, 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(0x9fc7d7, 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: 0x4f747c,
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: 0x5e7479,
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);
});
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 projectedPosition = new THREE.Vector3();
function positionTwinCallouts() {
const twin = state.seatTwin;
const model = twin.models[twin.selected];
const width = canvas.clientWidth;
const height = canvas.clientHeight;
scene.updateMatrixWorld(true);
camera.updateMatrixWorld();
Object.entries(model.anchors).forEach(([axis, anchor]) => {
anchor.getWorldPosition(projectedPosition);
projectedPosition.project(camera);
const callout = $(`[data-callout="${axis}"]`);
const includedAxis = {
backrest_included_angle: "backrest_recline",
legrest_included_angle: "legrest_height",
}[axis];
const supportedAxes = twinAxesForSeat(twin.selected);
const supported = supportedAxes.has(includedAxis || axis);
const requested = (
supported
&& (!includedAxis
|| twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null)
);
const visible = (
requested
&& projectedPosition.z > -1
&& projectedPosition.z < 1
);
callout.hidden = !visible;
if (!visible) return;
const anchorX = (projectedPosition.x * 0.5 + 0.5) * width;
const anchorY = (-projectedPosition.y * 0.5 + 0.5) * height;
const offset = {
longitudinal: { x: -104, y: 50 },
backrest_recline: { x: -108, y: -28 },
cushion_tilt: { x: 106, y: -4 },
legrest_height: { x: 112, y: 52 },
backrest_included_angle: { x: -112, y: 42 },
legrest_included_angle: { x: 114, y: 78 },
}[axis] || { x: 0, y: 0 };
const halfWidth = Math.max(46, callout.offsetWidth / 2);
const halfHeight = Math.max(22, callout.offsetHeight / 2);
const x = Math.max(halfWidth + 10, Math.min(width - halfWidth - 10, anchorX + offset.x));
const y = Math.max(halfHeight + 10, Math.min(height - halfHeight - 10, anchorY + offset.y));
callout.style.left = `${x}px`;
callout.style.top = `${y}px`;
const dx = anchorX - x;
const dy = anchorY - y;
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 startDistance = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary) + 3);
callout.style.setProperty("--leader-start-x", `${unitX * startDistance}px`);
callout.style.setProperty("--leader-start-y", `${unitY * startDistance}px`);
callout.style.setProperty("--leader-length", `${Math.max(0, distance - startDistance - 2)}px`);
callout.style.setProperty("--leader-angle", `${Math.atan2(dy, dx)}rad`);
callout.style.setProperty("--leader-dx", `${dx}px`);
callout.style.setProperty("--leader-dy", `${dy}px`);
});
}
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.24 / Math.max(0.72, camera.aspect),
1,
1.4,
);
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);
positionTwinCallouts();
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 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;
button.dataset.connectionState = ["online", "ready"].includes(runtime?.state_sync?.state)
? "online"
: "offline";
button.title = runtime?.state_configured === true
? runtime?.state_sync?.last_error || ""
: `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`;
});
const selected = selectedVehicleTransport();
const note = $("#vehicleTransportNote");
renderCabinLiveStatus();
if (!note) return;
if (selected?.state_configured !== true) {
note.textContent = state.controlTransport === "wireless_mqtt"
? "已手动选择无线控制 · MQTT 当前未配置或不可用"
: "已手动选择有线控制 · ZLG CAN 当前未配置或不可用";
return;
}
if (state.controlTransport === "wireless_mqtt") {
const connection = selected?.state_sync?.state === "online" ? "已连接" : "未连接";
note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/n7-headunit/can/command"}`;
} else {
const connection = selected?.state_sync?.state === "online"
? "已连接"
: selected?.state_sync?.state === "ready"
? "已配置"
: "未连接";
const dualChannel = selected?.target_policy
=== "dual_channel_wired_can0_passenger_can1_vehicle";
note.textContent = dualChannel
? `ZLG 有线 ${connection} · CAN0 副驾经典 CAN 500k · CAN1 车辆 CAN FD 500k/2M`
: `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`;
}
}
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";
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}"], [data-callout="${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, [data-callout="${axis}"] > span`)
.forEach((label) => { label.textContent = twinLabel; });
});
const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span');
const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span');
if (backrestIncludedLabel) backrestIncludedLabel.textContent = "靠背-坐垫夹角";
if (legrestIncludedLabel) legrestIncludedLabel.textContent = "坐垫-腿托夹角";
}
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 modeSecondsLabel(durationMs) {
const seconds = Number(durationMs) / 1000;
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
}
const MODE_GALLERY_GROUPS = [
{
id: "personal",
title: "我的模式",
description: "根据自然语言指令创建,可执行或删除",
icon: "bookmark",
modeIds: [],
userOnly: true,
},
{
id: "frequent",
title: "常用模式",
description: "高频座舱场景,可从主界面快捷执行",
icon: "sparkles",
modeIds: ["zero_gravity", "office", "nap", "queen_passenger_driving", "leisure_lounger"],
},
{
id: "comfort",
title: "舒享模式",
description: "休憩、冥想与乘坐舒适场景",
icon: "armchair",
modeIds: [
"cruise_sofa",
"easy_entry_exit",
"electric_beach_chair",
"calf_decompression_float",
"baby_nap",
"meditation_space",
],
},
{
id: "dynamic",
title: "动态模式",
description: "按受控节拍持续调整座椅姿态",
icon: "waves",
modeIds: ["rocking_horse", "dynamic_cradle"],
},
];
const MODE_DOMAIN_LABELS = {
seat: "座椅",
climate: "空调",
roof: "天幕",
ambient_light: "氛围灯",
window: "车窗",
front_light: "车灯",
vehicle_status: "车辆状态",
};
function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true;
}
function modeGalleryBuckets() {
const modes = modeProfiles();
const assignedIds = new Set(MODE_GALLERY_GROUPS.flatMap((group) => group.modeIds));
return MODE_GALLERY_GROUPS.map((definition) => {
let groupModes;
if (definition.userOnly) {
groupModes = modes.filter((mode) => mode?.origin === "user");
} else if (definition.id === "comfort") {
groupModes = definition.modeIds
.map((modeId) => modes.find((mode) => mode.id === modeId))
.filter(Boolean);
modes.filter((mode) => !isUserModeProfile(mode) && !assignedIds.has(mode.id))
.forEach((mode) => groupModes.push(mode));
} else {
groupModes = definition.modeIds
.map((modeId) => modes.find((mode) => mode.id === modeId))
.filter(Boolean);
}
return { ...definition, modes: groupModes };
}).filter((group) => group.modes.length > 0 || group.userOnly);
}
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 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 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.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.modes.length) {
group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
} else {
const empty = document.createElement("div");
empty.className = "mode-gallery-empty";
empty.innerHTML = '<i data-lucide="bookmark-plus"></i><span><strong>还没有自定义模式</strong><small>可在主界面根据当前指令创建</small></span>';
grid.append(empty);
}
section.append(grid);
container.append(section);
});
const modeCount = modeProfiles().length;
$("#modeGalleryCount").textContent = String(modeCount);
$("#modeGallerySummaryCount").textContent = String(modeCount);
refreshIcons();
}
function updateModeRailNavigation() {
const chips = $(".mode-chips");
const previous = $("#modeRailPrev");
const next = $("#modeRailNext");
if (!chips || !previous || !next) return;
const maxScroll = Math.max(0, chips.scrollWidth - chips.clientWidth);
previous.disabled = maxScroll <= 2 || chips.scrollLeft <= 2;
next.disabled = maxScroll <= 2 || chips.scrollLeft >= maxScroll - 2;
}
function scrollModeRail(direction) {
const chips = $(".mode-chips");
if (!chips) return;
const buttons = $$("button[data-mode-profile-id]", chips);
const viewport = chips.getBoundingClientRect();
const target = direction > 0
? buttons.find((button) => button.getBoundingClientRect().right > viewport.right + 2)
: [...buttons].reverse().find((button) => button.getBoundingClientRect().left < viewport.left - 2);
if (target) {
const targetLeft = direction > 0
? target.offsetLeft - 6
: target.offsetLeft + target.offsetWidth - chips.clientWidth + 6;
chips.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
} else {
chips.scrollBy({ left: direction * Math.max(220, chips.clientWidth * 0.72), behavior: "smooth" });
}
}
function openModeGallery() {
const backdrop = $("#modeGalleryBackdrop");
if (!backdrop) return;
closeSaveModeComposer();
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
: {};
const executionSteps = Array.isArray(result?.execution_report?.spec?.results)
? result.execution_report.spec.results
: [];
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: executionSteps,
};
}
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 控制流,但当前指令没有可读映射。"
: "点击“执行”后,这里会显示由后端生成的 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 ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域",
);
button.title = nextExpanded ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域";
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,
sourceRequestId: String(flow?.source_request_id || ""),
executedInstruction: flow ? instruction : "",
flow,
};
$("#modeEditorName").value = mode.name || "";
$("#modeEditorInstruction").value = instruction;
$("#modeEditorStatus").textContent = flow
? "已载入永久保存的自然语言和 CAN 指令流,可修改后重新执行。"
: "修改自然语言后需要重新执行,才能点击完成永久保存。";
setModeEditorFlowExpanded(false);
backdrop.hidden = false;
document.body.classList.add("mode-editor-open");
renderModeEditorFlow();
window.requestAnimationFrame(() => {
backdrop.classList.add("open");
$("#modeEditorName").focus();
});
refreshIcons();
}
function closeModeEditor({ restoreFocus = true } = {}) {
const backdrop = $("#modeEditorBackdrop");
const modeId = state.modeEditor?.modeId;
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 && modeId) {
$(`[data-mode-edit="${CSS.escape(modeId)}"]`)?.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
? "当前自然语言已有 CAN 指令流,可点击完成永久保存。"
: "自然语言已修改,请重新点击执行生成 CAN 控制指令流。";
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"></i><span>执行中…</span>';
$("#modeEditorComplete").disabled = true;
$("#modeEditorStatus").textContent = "正在解析自然语言并生成、执行 CAN 控制指令流…";
refreshIcons();
try {
const execution = await requestCommandAndWait(instruction);
const result = execution.result;
const flow = modeEditorFlowFromResult(result);
const commandCount = modeEditorCommandCount(flow);
if (!result || commandCount === 0) {
throw new Error(execution.error?.message || "未生成可保存的 CAN 控制指令流");
}
editor.flow = flow;
editor.sourceRequestId = result.request_id;
editor.executedInstruction = instruction;
$("#modeEditorStatus").textContent = `已生成 ${commandCount} 条 CAN 控制指令;执行状态:${result.status}。`;
setModeExecutionState(
editor.modeId,
["succeeded", "partially_succeeded"].includes(result.status) ? "succeeded" : "failed",
);
renderModeEditorFlow();
} catch (error) {
editor.flow = null;
editor.sourceRequestId = "";
editor.executedInstruction = "";
$("#modeEditorStatus").textContent = error.message || "CAN 控制指令流生成失败。";
renderModeEditorFlow();
showToast(error.message || "CAN 控制指令流生成失败");
} finally {
execute.disabled = false;
execute.classList.remove("loading");
execute.innerHTML = '<i data-lucide="play"></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.sourceRequestId
|| editor.executedInstruction !== instruction
|| modeEditorCommandCount(editor.flow) === 0
) {
showToast("请先执行当前自然语言并生成 CAN 控制指令流");
return;
}
const complete = $("#modeEditorComplete");
complete.disabled = true;
complete.classList.add("loading");
complete.innerHTML = '<i data-lucide="loader-circle"></i><span>保存中…</span>';
refreshIcons();
try {
const response = await apiRequest(`/api/mode-profiles/${encodeURIComponent(editor.modeId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
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 });
showToast(`${payload.mode_profile?.name || name}已永久保存到模式卡片`);
} catch (error) {
$("#modeEditorStatus").textContent = error.message || "模式保存失败。";
showToast(error.message || "模式保存失败");
renderModeEditorFlow();
} finally {
complete.classList.remove("loading");
complete.innerHTML = '<i data-lucide="save"></i><span>完成</span>';
refreshIcons();
}
}
function populateModeChips() {
const group = $(".mode-chips");
if (!group) return;
const buttons = () => $$("button", group);
const modes = modeProfiles();
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";
const labelNode = document.createElement("span");
labelNode.className = "mode-chip-label";
labelNode.textContent = label;
button.append(icon, labelNode);
group.append(button);
}
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 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 dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
const allButtons = buttons();
const userButtons = allButtons.filter((button) => button.dataset.modeOrigin === "user");
const dynamicButtons = dynamicModeOrder
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
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", group).forEach((divider) => divider.remove());
userButtons.forEach((button) => group.append(button));
if (userButtons.length && staticButtons.length) {
const userDivider = document.createElement("span");
userDivider.className = "mode-group-divider";
userDivider.dataset.modeDivider = "user";
userDivider.setAttribute("aria-hidden", "true");
group.append(userDivider);
}
preferredStaticButtons.forEach((button) => group.append(button));
otherStaticButtons.forEach((button) => group.append(button));
if (dynamicButtons.length && staticButtons.length) {
const divider = document.createElement("span");
divider.className = "mode-group-divider";
divider.setAttribute("aria-hidden", "true");
group.append(divider);
}
dynamicButtons.forEach((button) => group.append(button));
if (userButtons.length) group.scrollLeft = 0;
populateModeGallery();
refreshIcons();
window.requestAnimationFrame(updateModeRailNavigation);
}
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}”的初始预设配置?当前保存的名称、自然语言和 CAN 指令流将被删除。`
: `删除自定义功能模式“${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;
});
}
function applyModeCommand(command, modeProfileId = null) {
if (!command) return;
if ($("#runButton").disabled) {
showToast("上一条指令仍在执行,请稍候");
return;
}
closeModeGallery({ restoreFocus: false });
state.pendingModeProfileId = modeProfileId;
if (modeProfileId) setModeExecutionState(modeProfileId, "running");
$("#utterance").value = command;
$("#requestForm").requestSubmit();
}
function updateModeDraftPreview() {
const command = $("#utterance").value.trim();
state.modeDraftCommand = command;
const source = $("#saveModeSource");
source.textContent = command || "请先在指令框输入需要固化的功能组合";
source.classList.toggle("empty", !command);
}
function toggleSaveModeComposer() {
const composer = $("#saveModeComposer");
composer.hidden = !composer.hidden;
if (!composer.hidden) {
updateModeDraftPreview();
$("#saveModeName").focus();
}
}
function closeSaveModeComposer() {
$("#saveModeComposer").hidden = true;
state.modeDraftCommand = "";
}
function requestCommandAndWait(utterance) {
return new Promise((resolve) => {
const button = $("#runButton");
if (!button || button.disabled) {
resolve({ result: null, error: { message: "上一条指令仍在执行" } });
return;
}
const timeoutMs = requestTimeoutForExecutionMode(state.executionMode) + 5000;
const onComplete = (event) => {
if (event.detail?.utterance !== utterance) return;
window.clearTimeout(timer);
window.removeEventListener("cognitive-agent:request-complete", onComplete);
resolve(event.detail);
};
const timer = window.setTimeout(() => {
window.removeEventListener("cognitive-agent:request-complete", onComplete);
resolve({ result: null, error: { message: "指令执行等待超时" } });
}, timeoutMs);
window.addEventListener("cognitive-agent:request-complete", onComplete);
$("#utterance").value = utterance;
$("#requestForm").requestSubmit();
});
}
async function saveCurrentModeProfile() {
const input = $("#saveModeName");
const rawName = input.value.trim();
if (!rawName) {
showToast("请输入模式名称");
return;
}
const sourceCommand = state.modeDraftCommand || $("#utterance").value.trim();
if (!sourceCommand) {
showToast("请先输入需要创建为模式的用户指令");
$("#utterance").focus();
return;
}
const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`;
const confirm = $("#saveModeConfirm");
confirm.disabled = true;
confirm.classList.add("loading");
confirm.textContent = "创建中…";
try {
if (state.lastSuccessfulUtterance !== sourceCommand) {
showToast("正在执行当前指令,成功后将创建功能模式");
const execution = await requestCommandAndWait(sourceCommand);
if (!execution.result || !["succeeded", "partially_succeeded"].includes(execution.result.status)) {
throw new Error(execution.error?.message || "当前指令未成功执行,未创建功能模式");
}
}
const saveCommand = `这个模式不错,固化为${modeName}`;
const saved = await requestCommandAndWait(saveCommand);
if (!saved.result?.mode_profile || saved.result.status !== "succeeded") {
throw new Error(saved.error?.message || "功能模式创建失败");
}
await loadRuntime();
const createdModeId = saved.result.mode_profile.id;
setModeExecutionState(createdModeId, "succeeded");
closeSaveModeComposer();
input.value = "";
$("#utterance").value = sourceCommand;
showToast(`${saved.result.mode_profile.name}已创建并添加到功能模式栏`);
} catch (error) {
showToast(error.message || "功能模式创建失败");
} finally {
confirm.disabled = false;
confirm.classList.remove("loading");
confirm.textContent = "创建并添加";
}
}
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 new Error(`HTTP ${response.status}`);
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 || {};
$("#simulationMode").disabled = !simulation.enabled;
$("#simulationMode").title = simulation.enabled
? "开发专用模拟台架,不连接物理 CAN"
: "通过安卓联调脚本或环境变量启用模拟台架";
const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
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} 秒自动停止`
: "";
const vehicleRouteText = mqttSelected
? `MQTT ${vehicleCan.host || "127.0.0.1"}:${vehicleCan.port || 1883} · ${vehicleCan.condition_topic || "n7/n7-headunit/can/state"} / ${vehicleCan.control_topic || "n7/n7-headunit/can/command"}`
: `${vehicleCan.backend || "zlgcan"} · CH${vehicleCan.channel ?? 1} 车身 CAN FD ${Number(vehicleCan.arbitration?.bitrate || 500000) / 1000}k/${Number(vehicleCan.data?.bitrate || 2000000) / 1000000}M`;
$("#hardwareNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `${vehicleRouteText} · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}`
: linkedHardwareReady
? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${profileText} · ${flashText}`}${accessText}${concurrencyText}`
: seatHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
: `可手动选择真实台架;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
$("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止`
: linkedHardwareReady
? `${mqttDualChannel ? "MQTT CAN0" : "CH0"} → 副驾座椅控制器 · ${mqttDualChannel ? "MQTT CAN1" : "CH1"} → 其余车辆 CAN`
: seatHardwareReady
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
: "尚未配置可执行的物理 CAN 通道";
$("#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) 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 new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestedTransport !== state.controlTransport) return false;
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) {
if (!["physical_can", "wireless_mqtt_ch0"].includes(state.seatFeedbackSources.passenger)) {
state.seatFeedbackSources.passenger = "unavailable";
updateSeatFeedbackModeLabel();
}
return false;
}
}
async function synchronizeDriverSeatState() {
if (!selectedVehicleTransport()?.state_configured) return false;
const requestedTransport = state.controlTransport;
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 new Error(`HTTP ${response.status}`);
const payload = await response.json();
if (requestedTransport !== state.controlTransport) return false;
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) {
state.vehicleStateSnapshot = {
...(state.vehicleStateSnapshot || {}),
error: 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() {
$$(".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;
Object.entries(displayFunctions).forEach(([feature, value]) => {
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 modeSave = Boolean(result.mode_profile);
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,
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 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
? "长期模式配置已保存"
: 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);
if (result.mode_profile) loadRuntime();
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;
}
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 utterance = $("#utterance").value.trim();
const submittedModeProfileId = state.pendingModeProfileId;
state.pendingModeProfileId = null;
if (!utterance) {
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
showToast("请输入车舱控制请求");
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 requestBody = {
utterance,
client_type: "web_console",
provider: state.provider,
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
control_transport: state.controlTransport,
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
};
let payload;
let supplementWarning = "";
const onControlPhaseEvent = (phaseEvent) => {
if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
window.dispatchEvent(new CustomEvent(
"cognitive-agent:validated-can-commands",
{ detail: phaseEvent.payload },
));
};
if (state.provider === "openai") {
const preview = await postControlPhase(
"/api/requests/preview",
requestBody,
state.executionMode,
onControlPhaseEvent,
);
if (!preview) return;
renderResult(preview);
completedResult = preview;
if (preview.supplement_required) {
setRunStatus("processing", "CLOUD SUPPLEMENT");
$("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
try {
payload = await postControlPhase(
`/api/requests/${preview.request_id}/supplement`,
{
client_type: "web_console",
provider: state.provider,
reasoning_effort: state.reasoningEffort,
...(state.executionMode === "hardware"
&& state.controlTransport !== "wireless_mqtt"
&& !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
},
state.executionMode,
onControlPhaseEvent,
);
if (!payload) return;
} catch (error) {
const localCommandCount = Object.values(preview.function_configs || {})
.reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
if (!localCommandCount) throw error;
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 = "本地规则命令已执行;云端补充失败,已保留本地结果";
}
} else {
payload = preview;
}
} else {
payload = await postControlPhase(
"/api/requests",
requestBody,
state.executionMode,
onControlPhaseEvent,
);
if (!payload) return;
}
renderResult(payload);
completedResult = payload;
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 (completed) state.lastSuccessfulUtterance = utterance;
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() {
state.conversationId = createConversationId();
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
window.location.reload();
}
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) input.value = String(utterance);
renderResult(result);
return true;
},
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(() => {});
}
});
document.addEventListener("DOMContentLoaded", async () => {
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();
}
});
$("#newConversation").addEventListener("click", startNewConversation);
$("#vehicleSelect").addEventListener("change", (event) => {
selectVehicle(event.target.value);
});
$(".mode-chips").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button[data-mode-command]");
if (button) applyModeCommand(
button.dataset.modeCommand,
button.dataset.modeProfileId || null,
);
});
$(".mode-chips").addEventListener(
"scroll",
() => window.requestAnimationFrame(updateModeRailNavigation),
{ passive: true },
);
$("#modeRailPrev").addEventListener("click", () => scrollModeRail(-1));
$("#modeRailNext").addEventListener("click", () => scrollModeRail(1));
$("#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 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();
}
});
window.addEventListener("resize", () => window.requestAnimationFrame(updateModeRailNavigation));
$("#utterance").addEventListener("input", () => {
if (!$("#saveModeComposer").hidden) updateModeDraftPreview();
});
$("#saveModeToggle").addEventListener("click", toggleSaveModeComposer);
$("#saveModeCancel").addEventListener("click", closeSaveModeComposer);
$("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile);
$("#saveModeName").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault();
saveCurrentModeProfile();
}
});
$$(".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();
}
});