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 = `
VEHICLE DIGITAL TWIN · JOINT CONTROL

整车联合孪生总览

双通道前排座椅孪生:CH0 对应副驾座椅,CH1 对应主驾座椅;两侧状态相互独立。

副驾座椅 主驾座椅 空调 氛围灯 天幕
`; 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 = ` 正在读取车辆 CAN 状态 `; cabinHeading?.append(cabinHeadingActions); const cabinToolbar = document.createElement("div"); cabinToolbar.className = "cabin-board-toolbar"; cabinToolbar.innerHTML = `
当前档位
--
实时车速
-- km/h
门锁状态
状态未知
系统状态
等待状态
`; 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 = `
DEVICE POWER INSIGHT

设备功率详情

实时更新 · 等待功率数据

当前总功率 --W 等待测量数据
本次行程
-- kWh
今日累计
-- kWh
POWER BREAKDOWN

子部件当前功率

合计 -- W
POWER TREND

功率趋势

-- W 0
暂无功率趋势数据
行程开始当前
`; 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 = `
仅支持设备总功率

${activePowerDevice.name}暂无可读的子部件功率通道,顶部总功率仍可独立查看。

`; componentList.append(empty); $("#devicePowerComponentTotal", powerDrawer).textContent = "设备级测量"; } else { componentValues.forEach(({ label, value }) => { const row = document.createElement("div"); row.className = "device-power-component-row"; row.innerHTML = ` ${label} ${formatPower(value)}`; 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 = ` 实时功率-- W 详情`; entry.addEventListener("click", () => openPowerDrawer(device, entry)); } else { entry.setAttribute("aria-label", `${name}暂不支持功率测量`); entry.innerHTML = ` 功率测量暂不支持 无测量通道`; } 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 = ``; $("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 ", "#include \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 = '3D 引擎加载失败,精确状态仍可用'; 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 = '3D 渲染器初始化失败,状态数据仍可用'; 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 = '实车模型加载失败,已切换轻量回退模型'; 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 = '3D 上下文已中断,正在等待恢复'; 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 = '正在加载整车参考模型'; refreshIcons(); } else if (modelState === "failed") { fallback.classList.add("error"); fallback.innerHTML = '实车模型加载失败,已切换轻量回退模型'; 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) => ``).join("") : ''; 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 = '还没有自定义模式可在主界面根据当前指令创建'; 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 : {}; return { schema: "cognitive-agent-mode-command-flow/v1", source_request_id: result?.request_id || "", execution_mode: result?.execution_mode || "", control_transport: result?.control_transport || "", function_configs: functionConfigs, plans, execution_steps: [], }; } function modeEditorCommandCategory(command) { const value = String(command || "").trim(); if (/^ac temp\b/.test(value)) return "空调温度"; if (/^ac fan\b/.test(value)) return "空调风量"; if (/^ac (?:on|off)$/.test(value)) return "空调电源"; if (/^ac mode\b/.test(value)) return "出风模式"; if (/^ac control\b/.test(value)) return "空调控制方式"; if (/^ac cycle\b/.test(value)) return "空调循环"; if (/^window\b/.test(value)) return value.includes(" position ") ? "单窗位置" : "车窗动作"; if (/^shade\b/.test(value)) return "遮阳帘"; if (/^light ambient\b/.test(value)) return "氛围灯"; if (/^light reading\b/.test(value)) return "全部阅读灯"; if (/^light indoor\b/.test(value)) return "室内灯总开关"; if (/^seat passenger\b/.test(value)) return "副驾座椅控制"; if (/^seat driver\b/.test(value)) return "主驾座椅控制"; if (/^outlet\b/.test(value)) return "电动风口"; if (/^door rear\b/.test(value)) return "后排门锁"; if (/^door vehicle\b/.test(value)) return "整车门锁"; return "CAN 控制"; } function modeEditorPlanCommands(plan, operationRef = "") { const commands = Array.isArray(plan?.spec?.commands) ? plan.spec.commands : []; if (!operationRef) return commands; return commands.filter((item) => String(item?.operation_ref || "") .split(",") .map((value) => value.trim()) .includes(operationRef)); } function modeEditorCanId(value) { const normalized = String(value || "").trim().replace(/^0x/i, "").toUpperCase(); return normalized ? `0x${normalized}` : ""; } function modeEditorFrameFromPlan(command, readable) { const signals = command?.signals || {}; const can0Frame = String(readable?.frame || "").match(/^(0x[0-9A-F]+)\s*·\s*(.+)$/i); const frameId = modeEditorCanId(signals.frame_id || can0Frame?.[1]); const dataHex = String(signals.data_hex || can0Frame?.[2] || "").toUpperCase(); if (!frameId || !dataHex) return null; const channelIndex = readable?.channel === "CAN0" ? 0 : 1; const isFd = Boolean(signals.is_fd); return { frame_id: frameId, data_hex: dataHex, channel: channelIndex, is_fd: isFd, bitrate_switch: Boolean(signals.bitrate_switch), }; } function modeEditorNormalizeFrame(frame, readable) { if (!frame || typeof frame !== "object") return null; const frameId = modeEditorCanId(frame.frame_id || frame.canId); const dataHex = String(frame.data_hex || frame.data || "").toUpperCase(); if (!frameId || !dataHex) return null; const rawChannel = frame.channel; const channel = typeof rawChannel === "string" ? Number(rawChannel.replace(/^can/i, "")) : Number(rawChannel ?? (readable?.channel === "CAN0" ? 0 : 1)); const isFd = frame.is_fd === true || frame.frameType === "fd" || frame.frame_format === "can_fd"; return { frame_id: frameId, data_hex: dataHex, channel: Number.isInteger(channel) ? channel : readable?.channel === "CAN0" ? 0 : 1, is_fd: isFd, bitrate_switch: Boolean(frame.bitrate_switch ?? frame.brs), }; } function modeEditorMqttEnvelope(frame, command, requestId = "<运行时生成>") { const topic = state.runtime?.vehicle_mqtt?.control_topic || "n7/n7-headunit/can/command"; return { topic, qos: Number(state.runtime?.vehicle_mqtt?.qos ?? 1), retain: false, payload: { requestId, command, channel: `can${frame.channel}`, canId: frame.frame_id, frameType: frame.is_fd ? "fd" : "classic", brs: frame.bitrate_switch, extended: false, remote: false, data: frame.data_hex, repeat: 1, intervalMs: 0, responseTimeoutMs: Number(state.runtime?.vehicle_mqtt?.feedback_timeout_ms ?? 1500), }, }; } function modeEditorMessagesForOperation(flow, readable, operationRef, plan) { const steps = Array.isArray(flow?.execution_steps) ? flow.execution_steps : []; const matchingSteps = steps.filter((item) => String(item?.ref || "") === operationRef); const actualMessages = matchingSteps.flatMap((item) => { const telemetry = item?.telemetry && typeof item.telemetry === "object" ? item.telemetry : item; return Array.isArray(telemetry?.mqtt_messages) ? telemetry.mqtt_messages : []; }); if (actualMessages.length) { return actualMessages.map((message, index) => ({ sequence: index + 1, offset_ms: null, source: "published", ...message, })); } let frames = matchingSteps.flatMap((item) => { const telemetry = item?.telemetry && typeof item.telemetry === "object" ? item.telemetry : item; const sentFrames = Array.isArray(telemetry?.sent_frames) ? telemetry.sent_frames : []; if (sentFrames.length) return sentFrames; return telemetry?.command_frame ? [telemetry.command_frame] : []; }).map((frame) => modeEditorNormalizeFrame(frame, readable)).filter(Boolean); const planMessages = []; if (!frames.length) { modeEditorPlanCommands(plan, operationRef).forEach((command) => { const frame = modeEditorFrameFromPlan(command, readable); if (!frame) return; let special = null; try { special = JSON.parse(String(command?.signals?.special_parameters || "null")); } catch (_error) { special = null; } if (command?.signals?.special_handler === "driver_seat_memory_recall" && Array.isArray(special?.frame_sequence)) { const baseOffset = Math.max(0, Number(command?.send_offset_ms || 0)); const period = Math.max(0, Number(special.period_ms || 0)); special.frame_sequence.forEach((dataHex, index) => { planMessages.push({ frame: { ...frame, data_hex: String(dataHex).toUpperCase() }, offset_ms: baseOffset + (index * period), }); }); return; } const repeat = Math.max(1, Number(command?.repeat?.count || 1)); const period = Math.max(0, Number(command?.repeat?.period_ms || 0)); const baseOffset = Math.max(0, Number(command?.send_offset_ms || 0)); for (let index = 0; index < repeat; index += 1) { planMessages.push({ frame, offset_ms: baseOffset + (index * period) }); } }); } else { frames = frames.map((frame, index) => ({ frame, offset_ms: index === 0 ? 0 : null })); } const candidates = frames.length ? frames : planMessages; return candidates.map((item, index) => { const base = { sequence: index + 1, offset_ms: item.offset_ms, source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can", }; return flow?.control_transport === "wireless_mqtt" ? { ...base, ...modeEditorMqttEnvelope(item.frame, readable.command) } : { ...base, frame: item.frame }; }); } function modeEditorReadableCommands(flow) { if (!flow) return []; const plans = flow.plans && typeof flow.plans === "object" ? flow.plans : {}; const configurations = flow.function_configs && typeof flow.function_configs === "object" ? flow.function_configs : {}; const converter = window.CognitiveAgentConsoleV2?.getParsedCanCommands; const commands = []; if (typeof converter === "function") { Object.entries(configurations).forEach(([key, configuration]) => { const plan = plans[key]; if (!plan) return; (configuration?.spec?.operations || []).forEach((operation) => { const singleConfig = { ...configuration, spec: { ...configuration.spec, operations: [operation] }, }; const readable = converter({ function_configs: { [key]: singleConfig }, command_plans: { [key]: plan }, })[0]; if (!readable) return; commands.push({ ...readable, operation_ref: String(operation.id || ""), messages: modeEditorMessagesForOperation( flow, readable, String(operation.id || ""), plan, ), }); }); }); } if (!commands.length) { Object.values(plans).forEach((plan) => { modeEditorPlanCommands(plan).forEach((item) => { const debugCommand = String(item?.signals?.debug_command || item?.message || "CAN frame"); const channel = new Set(["0x29E", "0x300", "0x3CE"]).has( modeEditorCanId(item?.signals?.frame_id), ) ? "CAN0" : "CAN1"; const readable = { channel, category: modeEditorCommandCategory(debugCommand), command: debugCommand }; commands.push({ ...readable, operation_ref: String(item?.operation_ref || ""), messages: modeEditorMessagesForOperation( flow, readable, String(item?.operation_ref || ""), plan, ), }); }); }); } const unique = new Map(); commands.forEach((item) => { const key = `${item.channel}:${item.command}`; if (!unique.has(key)) unique.set(key, item); else unique.get(key).messages.push(...item.messages); }); return [...unique.values()]; } function modeEditorAppendCommandCard(preview, item, flow) { const card = document.createElement("details"); card.className = "mode-editor-command-card"; card.dataset.canChannel = item.channel; const summary = document.createElement("summary"); const meta = document.createElement("span"); meta.className = "mode-editor-command-meta"; const channel = document.createElement("b"); channel.textContent = item.channel; const category = document.createElement("strong"); category.textContent = item.category; meta.append(channel, category); const command = document.createElement("code"); command.textContent = item.command; const chevron = document.createElement("i"); chevron.dataset.lucide = "chevron-down"; summary.append(meta, command, chevron); card.append(summary); const sequence = document.createElement("section"); sequence.className = "mode-editor-message-sequence"; const header = document.createElement("header"); const title = document.createElement("span"); title.textContent = flow?.control_transport === "wireless_mqtt" ? "MQTT 消息序列" : "CAN 帧序列(未经过 MQTT)"; const messageCount = document.createElement("b"); messageCount.textContent = `${item.messages.length} 条`; header.append(title, messageCount); sequence.append(header); if (!item.messages.length) { const empty = document.createElement("p"); empty.textContent = "该指令当前没有可展示的已编译帧。"; sequence.append(empty); } else { const list = document.createElement("ol"); item.messages.forEach((message) => { const row = document.createElement("li"); const rowHeader = document.createElement("div"); const sequenceLabel = document.createElement("span"); const offset = Number.isFinite(message.offset_ms) ? ` · +${message.offset_ms}ms` : ""; sequenceLabel.textContent = `#${message.sequence}${offset}`; const topic = document.createElement("code"); topic.textContent = message.topic || `can${message.frame?.channel ?? "-"}`; rowHeader.append(sequenceLabel, topic); const payload = document.createElement("pre"); payload.textContent = JSON.stringify( message.payload ? { topic: message.topic, qos: message.qos, retain: message.retain, payload: message.payload } : message.frame, null, 2, ); row.append(rowHeader, payload); list.append(row); }); sequence.append(list); } card.append(sequence); preview.append(card); } function renderModeEditorFlow() { const editor = state.modeEditor; const preview = $("#modeEditorFlowPreview"); const count = $("#modeEditorFlowCount"); const complete = $("#modeEditorComplete"); if (!preview || !count || !complete) return; const commandCount = modeEditorCommandCount(editor?.flow); const readableCommands = modeEditorReadableCommands(editor?.flow); const instructionMatches = Boolean( editor?.executedInstruction && editor.executedInstruction === $("#modeEditorInstruction").value.trim(), ); complete.disabled = !(commandCount > 0 && instructionMatches && editor?.sourceRequestId); count.textContent = commandCount > 0 ? `${readableCommands.length || commandCount} 条指令` : "尚未生成"; preview.classList.toggle("empty", commandCount === 0); preview.replaceChildren(); if (commandCount > 0 && readableCommands.length) { readableCommands.forEach((item) => modeEditorAppendCommandCard(preview, item, editor.flow)); refreshIcons(); } else { const empty = document.createElement("p"); empty.textContent = commandCount > 0 ? "已生成 CAN 控制流,但当前指令没有可读映射。" : "点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。"; preview.append(empty); } } function setModeEditorFlowExpanded(expanded) { const backdrop = $("#modeEditorBackdrop"); const dialog = backdrop?.querySelector(".mode-editor"); const button = $("#modeEditorFlowExpand"); if (!backdrop || !dialog || !button) return; const nextExpanded = Boolean(expanded); backdrop.classList.toggle("flow-expanded", nextExpanded); dialog.classList.toggle("flow-expanded", nextExpanded); button.setAttribute("aria-expanded", String(nextExpanded)); button.setAttribute( "aria-label", nextExpanded ? "还原控制指令区域" : "展开控制指令区域", ); button.title = nextExpanded ? "还原控制指令区域" : "展开控制指令区域"; button.innerHTML = ``; 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 ? "已载入永久保存的自然语言和控制指令流,可修改后重新生成。" : "修改自然语言后需要重新生成指令,才能点击完成永久保存。"; 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 ? "当前自然语言已有控制指令流,可点击完成永久保存。" : "自然语言已修改,请重新点击“生成指令”。"; 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 = '生成中…'; $("#modeEditorComplete").disabled = true; $("#modeEditorStatus").textContent = "正在复用主页解析流程生成控制指令;不会执行设备…"; refreshIcons(); try { const { payload: result } = await requestNaturalLanguageControl(instruction, { executionMode: "dry_run", clientType: "mode_editor_compile", previewOnly: true, }); const flow = modeEditorFlowFromResult(result); const commandCount = modeEditorCommandCount(flow); if (!result || commandCount === 0) { throw new Error("未生成可保存的控制指令流"); } editor.flow = flow; editor.sourceRequestId = result.request_id; editor.executedInstruction = instruction; $("#modeEditorStatus").textContent = `已生成 ${commandCount} 条控制指令;尚未执行设备。点击“完成”保存到模式卡片。`; renderModeEditorFlow(); } catch (error) { editor.flow = null; editor.sourceRequestId = ""; editor.executedInstruction = ""; $("#modeEditorStatus").textContent = error.message || "控制指令流生成失败。"; renderModeEditorFlow(); showToast(error.message || "控制指令流生成失败"); } finally { execute.disabled = false; execute.classList.remove("loading"); execute.innerHTML = '生成指令'; 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("请先根据当前自然语言生成控制指令流"); return; } const complete = $("#modeEditorComplete"); complete.disabled = true; complete.classList.add("loading"); complete.innerHTML = '保存中…'; 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 = '完成'; 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}”的初始预设配置?当前保存的名称、自然语言和控制指令流将被删除。` : `删除自定义功能模式“${mode.name}”?删除后无法恢复。`; if (!window.confirm(confirmation)) return; const deleteButtons = $$(`[data-mode-delete="${CSS.escape(modeProfileId)}"]`); deleteButtons.forEach((button) => { button.disabled = true; button.setAttribute("aria-busy", "true"); }); try { const query = new URLSearchParams({ vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id || "", }); const response = await apiRequest( `/api/mode-profiles/${encodeURIComponent(modeProfileId)}?${query}`, { method: "DELETE" }, ); const payload = await response.json(); if (response.status === 401) { window.location.replace("/login"); return; } if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`); await loadRuntime(); showToast(presetOverride ? `${mode.name}已恢复初始预设` : `${mode.name}已删除`); } catch (error) { showToast(`删除失败:${error.message || "服务不可用"}`); deleteButtons.forEach((button) => { button.disabled = false; button.removeAttribute("aria-busy"); }); } } function setModeExecutionState(modeProfileId, modeState = "idle") { window.clearTimeout(state.modeRunTimer); state.modeRunTimer = null; $$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => { const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle"; button.classList.toggle("running", active && modeState === "running"); button.classList.toggle("succeeded", active && modeState === "succeeded"); button.classList.toggle("failed", active && modeState === "failed"); button.dataset.modeState = active ? modeState : "idle"; button.setAttribute("aria-pressed", active ? "true" : "false"); button.setAttribute("aria-busy", active && modeState === "running" ? "true" : "false"); const stateLabel = $(".mode-gallery-state", button); if (stateLabel) { stateLabel.textContent = active && modeState === "running" ? "执行中" : active && modeState === "succeeded" ? "已完成" : active && modeState === "failed" ? "执行失败" : "点击执行"; } }); if (modeProfileId && ["succeeded", "failed"].includes(modeState)) { state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800); } } function setModeChipsDisabled(disabled) { $$(".mode-chips button[data-mode-command], #modeGallery button[data-mode-command]").forEach((button) => { button.disabled = disabled; }); } async function executeSavedModeProfile(mode) { if (!mode?.id || !mode?.command_flow?.source_request_id) return false; const driverOnlyCh1 = isDriverOnlyCh1Runtime(); if (state.executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 && !isLocalDecisionHost() && state.hardwareAccess?.state !== "approved") { showToast(state.hardwareAccess?.state === "pending" ? "申请仍在等待本地审批" : "请先申请并获得本地真实台架使用权"); return true; } closeModeGallery({ restoreFocus: false }); const runButton = $("#runButton"); runButton.disabled = true; setModeChipsDisabled(true); setModeExecutionState(mode.id, "running"); $("#utterance").value = mode.name || mode.id; setRunStatus("processing", "MODE COMMAND FLOW"); $("#resultTitle").textContent = `正在直接执行${mode.name}`; try { const payload = await postControlPhase( `/api/mode-profiles/${encodeURIComponent(mode.id)}/execute`, { client_type: "web_console_mode_card", conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: state.executionMode, control_transport: state.controlTransport, ...(state.executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 ? { hardware_client_id: state.hardwareClientId } : {}), ...(state.executionMode === "simulated_hardware" ? { simulation_fault: $("#simulationFault").value } : {}), }, state.executionMode, ); if (!payload) throw new Error("模式卡片未返回执行结果"); renderResult(payload); const completed = ["succeeded", "partially_succeeded"].includes(payload.status); $("#utterance").value = mode.name || mode.id; $("#resultTitle").textContent = completed ? `${mode.name}执行完成` : `${mode.name}执行未完成`; setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); showToast(completed ? `${mode.name}已直接执行保存的控制指令流` : `${mode.name}未完成,请查看执行结果`); } catch (error) { setModeExecutionState(mode.id, "failed"); setRunStatus("failed", "MODE FAILED"); $("#resultTitle").textContent = `${mode.name}执行失败`; showToast(error.message || "模式执行失败"); } finally { runButton.disabled = false; setModeChipsDisabled(false); } return true; } function applyModeCommand(command, modeProfileId = null) { if (!command) return; if ($("#runButton").disabled) { showToast("上一条指令仍在执行,请稍候"); return; } const mode = modeProfiles().find((item) => item.id === modeProfileId); if (mode?.command_flow?.source_request_id) { void executeSavedModeProfile(mode); 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 = "PASSall公共 Schema、领域 Schema 和语义约束均通过。"; 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}℃` : "--℃"; $("#passengerTemperature").innerHTML = hasPassengerTemperature ? `${passengerTemperature}℃` : "--℃"; $("#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 = '暂无可展示的功能配置原始配置仍可在下方展开查看。'; 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 = ''; 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 = ``; 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 = ''; 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 = `Frames${frames.length}First payload${frames[0].frame_id} · ${frames[0].data_hex}Feedback${feedback}Mode${mode}`; $("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`; } else if (telemetry?.operation === "save_posture_preset") { summary.innerHTML = `OperationPRESET STORECANNO FRAME EMITTEDScopeCONVERSATION`; $("#planNavMeta").textContent = "预设存储 · 无 CAN 帧"; } else { summary.innerHTML = "Frame ID--Payload--ModeNOT EXECUTED"; $("#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 = `
执行未开始${blockedReason}
`; $("#resultNavMeta").textContent = "未执行 · 查看原因"; $("#resultPanelState").textContent = "BLOCKED"; $("#resultPanelState").className = "workspace-panel-state failed"; refreshIcons(); return; } const verificationStatus = executionVerificationStatus(result); const submittedUnverified = verificationStatus === "submitted_unverified"; const reportSucceeded = report.spec.status === "succeeded"; const partiallySucceeded = result.status === "partially_succeeded"; const peripheralRoute = String(result.orchestration_route || "") .startsWith("nfc_peripheral"); summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`; const presetSave = Boolean(result.preset_config); const savedModeExecution = result.orchestration_route === "saved_mode_command_flow"; const modeSave = Boolean(result.mode_profile) && !savedModeExecution; const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware"; const physical = report.spec.mode === "hil" && !simulatedHil; const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))]; const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length; const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified); const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware"); const succeededOperations = operationResults.filter((item) => item.status === "succeeded"); const failedOperations = operationResults.filter((item) => item.status === "failed"); const submittedOperations = operationResults.filter( (item) => operationVerificationStatus(item) === "submitted_unverified", ); const physicallyConfirmedOperations = operationResults.filter( (item) => item.telemetry?.physical_effect_confirmed === true, ); const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))]; const failedDomains = [...new Set(failedOperations.map((item) => item.domain))]; let executionDetail = modeSave ? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令` : presetSave ? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令" : simulatedHil ? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN` : physical ? submittedUnverified ? `${executedDomains.join(" / ")} 已通过${result.control_transport === "wireless_mqtt" ? " MQTT" : "有线 CAN"}提交 ${submittedOperations.length || succeededOperations.length} 个白名单控制步骤;车辆效果尚未确认,以实时状态回读为准` : result.control_transport === "wireless_mqtt" ? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${succeededOperations.length} 个白名单控制步骤,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态` : selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only" ? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤` : `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤` : `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`; if (partiallySucceeded) { executionDetail = `部分执行完成:${succeededDomains.join(" / ")} 成功 ${succeededOperations.length} 项;${failedDomains.join(" / ")} 未完成 ${failedOperations.length} 项,失败设备未阻断其他设备`; } if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`; if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件"; else if (firmwareResult) executionDetail += ";板上统一固件协议签名已校验"; if (result.deferred_requests?.length) { executionDetail += ";未接入域请求已保留为 deferred request"; } if (peripheralRoute && operationResults[0]?.message) { executionDetail = result.sequence_summary || operationResults[0].message; } if (verificationStatus === "failed_or_blocked" && !partiallySucceeded) { const failure = report.spec.results.find((item) => item.status === "failed"); const failureTelemetry = failure?.telemetry || {}; executionDetail = simulatedHil ? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。` : failureTelemetry.error_type === "CanGatewayError" ? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}` : failureTelemetry.error_type === "FirmwareProgrammingError" ? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}` : failureTelemetry.calibration_required ? `大幅相对移动已转换为绝对目标,但 Motor${failureTelemetry.motor_selector} 的 Position_Valid=0;未发送绝对移动命令,请先在台架清空并备好急停后执行显式端点标定。` : failureTelemetry.failure_category === "stall_no_hall_activity" ? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},未检测到持续 Hall 活动;可能已到目标方向机械端点/存在阻挡,或 Hall 供电、信号线未接通。请先安全地反向移动 2° 诊断。` : failureTelemetry.failure_category === "stall_after_hall_activity" ? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},运动后发生堵转,请检查目标方向端点或机械阻挡。` : failureTelemetry.motor_result_name ? `Motor${failureTelemetry.motor_selector} ${failureTelemetry.motor_result_name},板端事件 ${failureTelemetry.last_motor_event_name || "UNAVAILABLE"};${failureTelemetry.stop_all_attempted ? "已尝试发送 STOP_ALL。" : "请检查硬件回读。"}` : failureTelemetry.stop_all_attempted ? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。" : failure?.message || "真实台架未返回满足目标的物理反馈。"; } const executionTitle = reportSucceeded ? peripheralRoute ? (submittedUnverified ? "外设指令已发布" : "外设动作已执行") : modeSave ? "长期模式已保存" : presetSave ? "姿态预设已保存" : simulatedHil ? "模拟台架执行成功" : physical ? submittedUnverified ? `${result.control_transport === "wireless_mqtt" ? "MQTT" : "CAN"} 指令已提交,等待状态确认` : "真实台架执行并验证成功" : "虚拟执行成功" : partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功") : simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败"; const executionIcon = submittedUnverified ? "circle-alert" : reportSucceeded ? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big") : partiallySucceeded ? "circle-alert" : "circle-x"; summary.innerHTML = `
${executionTitle}${executionDetail}
`; $("#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 = ``; 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 ? savedModeExecution ? `${result.mode_profile.name}执行完成` : "长期模式配置已保存" : result.preset_config ? "座椅姿态预设已保存" : submittedUnverified ? `${domains.length > 1 ? `${domains.length} 域场景` : domainLabels[domains[0]] || "车舱"}控制指令已提交` : result.scene_config ? `${domains.length} 域场景闭环完成` : `${domainLabels[domains[0]] || "车舱"}单域闭环完成` : "请求未执行"; const conversationLabel = result.conversation_id?.slice(-8) || "no-context"; $("#requestMeta").textContent = `${result.request_id} / context:${conversationLabel} / turn:${result.conversation_turn} / ${result.provider_used || result.provider_requested}`; $("#requestMeta").textContent += ` / ${result.execution_report?.spec?.mode || result.execution_mode || "dry_run"}`; if (result.reasoning_effort_used) { $("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`; } setRunStatus( partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed", submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(), ); renderDecision(result); renderTrace(result.trace_events || []); renderValidation(result.validation || []); const ch1HardwareResult = reportMode === "hil" && selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"; if (ch1HardwareResult) { applyAuthoritativeVehicleState(result); } else { renderSeatState(result.actuator_state); renderDomainState(result.domain_state); updateSeatFeedbackModeLabel(); } updateTwinExplicitAngles(result); renderSavedPresets(result); 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; } function naturalLanguageRequestBody( utterance, { executionMode = state.executionMode, clientType = "web_console", } = {}, ) { const driverOnlyCh1 = isDriverOnlyCh1Runtime(); return { utterance, client_type: clientType, provider: state.provider, conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: executionMode, control_transport: state.controlTransport, ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), ...(executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 ? { hardware_client_id: state.hardwareClientId } : {}), ...(executionMode === "simulated_hardware" ? { simulation_fault: $("#simulationFault").value } : {}), }; } async function requestNaturalLanguageControl( utterance, { executionMode = state.executionMode, clientType = "web_console", previewOnly = false, onControlPhaseEvent = null, onPreview = null, onSupplementStart = null, } = {}, ) { const requestBody = naturalLanguageRequestBody(utterance, { executionMode, clientType, }); const usePreview = previewOnly || state.provider === "openai"; if (!usePreview) { const payload = await postControlPhase( "/api/requests", requestBody, executionMode, onControlPhaseEvent, ); return { payload, supplementWarning: "" }; } const preview = await postControlPhase( "/api/requests/preview", requestBody, executionMode, onControlPhaseEvent, ); if (!preview) return { payload: null, supplementWarning: "" }; if (typeof onPreview === "function") onPreview(preview); if (!preview.supplement_required) { return { payload: preview, supplementWarning: "" }; } if (typeof onSupplementStart === "function") onSupplementStart(preview); try { const payload = await postControlPhase( `/api/requests/${preview.request_id}/supplement`, { client_type: clientType, provider: state.provider, reasoning_effort: state.reasoningEffort, ...(executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !isDriverOnlyCh1Runtime() ? { hardware_client_id: state.hardwareClientId } : {}), }, executionMode, onControlPhaseEvent, ); return { payload, supplementWarning: "" }; } catch (error) { const localCommandCount = Object.values(preview.function_configs || {}) .reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0); if (!localCommandCount) throw error; return { payload: { ...preview, status: "partially_succeeded", supplement_required: false, cloud_supplement_error: { code: error.code || "cloud_supplement_failed", message: error.message, }, decision_summary: [ ...(preview.decision_summary || []), `云端补充失败:${error.message};本地规则命令已保留且不会撤销。`, ], }, supplementWarning: previewOnly ? "本地规则指令已生成;云端补充失败" : "本地规则命令已执行;云端补充失败,已保留本地结果", }; } } async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) { const commands = Array.isArray(canCommands) ? canCommands.map((command) => String(command || "").trim()).filter(Boolean) : []; if (!commands.length) throw new Error("没有可发送的 CAN 白名单控制指令"); if (state.directCanCommandPending) throw new Error("上一组 CAN 控制指令仍在执行,请稍候"); const driverOnlyCh1 = isDriverOnlyCh1Runtime(); if (state.executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 && !isLocalDecisionHost() && state.hardwareAccess?.state !== "approved") { throw new Error(state.hardwareAccess?.state === "pending" ? "申请仍在等待本地审批" : "请先申请并获得本地真实台架使用权"); } state.directCanCommandPending = true; try { const payload = await postControlPhase( "/api/can/commands", { commands, client_type: "web_console_device_control", conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: state.executionMode, control_transport: state.controlTransport, ...(state.executionMode === "hardware" && state.controlTransport !== "wireless_mqtt" && !driverOnlyCh1 ? { hardware_client_id: state.hardwareClientId } : {}), ...(state.executionMode === "simulated_hardware" ? { simulation_fault: $("#simulationFault").value } : {}), }, state.executionMode, ); if (!payload) throw new Error("CAN 控制请求未返回结果"); renderResult(payload); if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); showToast(`${deviceName} CAN 控制已发送`); return payload; } finally { state.directCanCommandPending = false; } } async function submitRequest(event) { event.preventDefault(); const 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 = '处理中'; refreshIcons(); setRunStatus("processing", "PROCESSING"); $("#resultTitle").textContent = "正在执行车舱编排"; try { const onControlPhaseEvent = (phaseEvent) => { if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return; window.dispatchEvent(new CustomEvent( "cognitive-agent:validated-can-commands", { detail: phaseEvent.payload }, )); }; const { payload, supplementWarning } = await requestNaturalLanguageControl( utterance, { onControlPhaseEvent, onPreview: (preview) => { renderResult(preview); completedResult = preview; }, onSupplementStart: () => { setRunStatus("processing", "CLOUD SUPPLEMENT"); $("#resultTitle").textContent = "本地指令已处理,正在补充云端解析"; }, }, ); 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 = `${buttonLabel}`; 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(); } });