From 156b10fda1a3ece7d70bc86e3379b13740ca7829 Mon Sep 17 00:00:00 2001 From: vee Date: Fri, 7 Aug 2026 09:18:17 +0800 Subject: [PATCH] feat: refactor vehicle and gesture control console --- apps/web-console/app.js | 1058 +++++++- apps/web-console/console-v2.css | 2362 +++++++++++++++++ apps/web-console/console-v2.js | 667 +++++ apps/web-console/gesture-control.js | 209 +- apps/web-console/index.html | 80 +- tests/e2e/test_seat_web_api.py | 93 +- .../frontend-layout-review/page-2-user.png | Bin 0 -> 100403 bytes .../frontend-layout-review/page-5-admin.png | Bin 0 -> 112027 bytes 8 files changed, 4372 insertions(+), 97 deletions(-) create mode 100644 apps/web-console/console-v2.css create mode 100644 apps/web-console/console-v2.js create mode 100644 tmp/pdfs/frontend-layout-review/page-2-user.png create mode 100644 tmp/pdfs/frontend-layout-review/page-5-admin.png diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 01b8085..f747c88 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -110,6 +110,18 @@ function initJointTwinLayout() { const seatFeedback = $("#seatFeedbackModeLabel"); if (seatFeedback) passengerCard.prepend(seatFeedback); domainGrid.prepend(passengerCard); + const updateDomainGridScrollState = () => { + const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2; + const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2; + domainGrid.dataset.scrollable = String(isScrollable); + domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom); + }; + domainGrid.addEventListener("scroll", updateDomainGridScrollState, { passive: true }); + if ("ResizeObserver" in window) { + const domainGridObserver = new ResizeObserver(updateDomainGridScrollState); + domainGridObserver.observe(domainGrid); + } + requestAnimationFrame(updateDomainGridScrollState); coverage.classList.add("joint-hardware-boundary"); shell.append(coverage); stateBand.remove(); @@ -501,10 +513,10 @@ function createTwinCabin(THREE, scene) { opacity: 0.56, }); const roofEntityEdge = new THREE.LineBasicMaterial({ - color: 0xbfd5ff, + color: 0x086861, transparent: true, - opacity: 1, - depthTest: false, + opacity: 0.98, + depthTest: true, }); const ventEntityEdge = new THREE.LineBasicMaterial({ color: 0x9eeaff, @@ -622,12 +634,13 @@ function createTwinCabin(THREE, scene) { rearGlass.renderOrder = 4; const roofGlassMaterial = glassMaterial.clone(); - roofGlassMaterial.color.setHex(0x173778); - roofGlassMaterial.emissive.setHex(0x153b82); - roofGlassMaterial.emissiveIntensity = 0.46; + roofGlassMaterial.color.setHex(0x258f89); + roofGlassMaterial.emissive.setHex(0x124c49); + roofGlassMaterial.emissiveIntensity = 0.38; roofGlassMaterial.transmission = 0; - roofGlassMaterial.opacity = 0.98; - roofGlassMaterial.depthWrite = true; + roofGlassMaterial.transparent = true; + roofGlassMaterial.opacity = 0.52; + roofGlassMaterial.depthWrite = false; const roofGlass = makeTwinPanel( THREE, cabin, @@ -641,12 +654,15 @@ function createTwinCabin(THREE, scene) { roofGlass.castShadow = false; roofGlass.renderOrder = 7; const roofFrameMaterial = new THREE.MeshStandardMaterial({ - color: 0x94bfff, - emissive: 0x326fd2, - emissiveIntensity: 0.78, + color: 0x086861, + emissive: 0x124c49, + emissiveIntensity: 0.38, roughness: 0.24, metalness: 0.62, - depthTest: false, + transparent: true, + opacity: 0.88, + depthWrite: false, + depthTest: true, }); const roofMeshes = [roofGlass]; [ @@ -802,10 +818,10 @@ function createTwinCabin(THREE, scene) { }); const lampMaterial = new THREE.MeshStandardMaterial({ - color: 0x697b80, - emissive: 0x000000, - emissiveIntensity: 0, - roughness: 0.56, + color: 0x5d7076, + emissive: 0xb9ecff, + emissiveIntensity: 0.08, + roughness: 0.34, }); const rearLampMaterial = new THREE.MeshStandardMaterial({ color: 0x697b80, @@ -813,11 +829,13 @@ function createTwinCabin(THREE, scene) { 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); @@ -944,14 +962,28 @@ function createTwinCabin(THREE, scene) { 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, @@ -969,12 +1001,32 @@ function createTwinCabin(THREE, scene) { 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, @@ -983,6 +1035,78 @@ function createTwinCabin(THREE, scene) { }; } +const TWIN_THEME_PALETTES = { + user: { + glass: 0x258f89, + glassEmissive: 0x124c49, + glassEdge: 0x086861, + glassGlow: 0x49e1d6, + roofClear: 0x4aa9a3, + roofDark: 0x0a5552, + lamp: 0x087b74, + lampEmissive: 0x16aaa1, + lampEdge: 0x096d67, + }, + admin: { + glass: 0x477bc4, + glassEmissive: 0x1f427c, + glassEdge: 0x234f91, + glassGlow: 0x86b3ff, + roofClear: 0x7299d5, + roofDark: 0x1d3c72, + lamp: 0x315eaa, + lampEmissive: 0x5688dc, + lampEdge: 0x244a86, + }, +}; + +function currentTwinThemeRole() { + return document.body.dataset.demoRole === "admin" + || sessionStorage.getItem("cognitive-agent.demo-role") === "admin" + ? "admin" + : "user"; +} + +function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) { + if (!systems) return; + const themeRole = role === "admin" ? "admin" : "user"; + const palette = TWIN_THEME_PALETTES[themeRole]; + systems.themeRole = themeRole; + 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 updateTwinVehicleVisual(systems, timestamp) { if (!systems) return; const ease = (current, target, factor = 0.08) => ( @@ -1004,6 +1128,16 @@ function updateTwinVehicleVisual(systems, timestamp) { 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( @@ -1024,13 +1158,62 @@ function updateTwinVehicleVisual(systems, timestamp) { 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.86 + (1 - roofRatio) * 0.1; + 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 @@ -1663,15 +1846,31 @@ function classifyReferenceMaterial(material) { const TWIN_OUTLINE_STYLE = { body: { color: 0x315d69, - lineOpacity: 0.36, + lineOpacity: 0.48, occludedColor: 0xa7b8bb, - occludedLineOpacity: 0.12, + occludedLineOpacity: 0.1, fillOpacity: 0.11, - thresholdAngle: 58, + thresholdAngle: 38, renderOrder: 30, }, - glass: { color: 0x78a7b2, lineOpacity: 0, fillOpacity: 0.045, renderOrder: 31 }, - detail: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.028, renderOrder: 32 }, + 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, @@ -1834,17 +2033,78 @@ function configureActivePassengerSeatStyle(model) { }); edge.color.setHex(0xffd592); edge.opacity = 1; - edge.depthTest = false; + edge.depthTest = true; + edge.depthWrite = false; seam.color.setHex(0xfff1cd); seam.opacity = 1; - seam.depthTest = false; + seam.depthTest = true; + seam.depthWrite = false; model.thermalColors.base.setHex(0xffc866); model.root.traverse((object) => { if (!object.isLine && !object.isLineSegments) return; - object.renderOrder = 55; + 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(); @@ -1941,16 +2201,20 @@ function configureReferenceVehicleModel(THREE, vehicle) { const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial); if (categories.length && categories.every((category) => category === "shadow")) object.visible = false; if (!object.visible) return; - const categoryPriority = ["light", "interior", "wheel", "detail", "glass", "body"]; + 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; - addTwinMeshOutline( - THREE, - object, - outlineStyle, - outlineStyle.thresholdAngle ?? 60, - ); + const isControllableWindowPane = String(object.userData.twinRole || "") + .startsWith("reference_window_"); + if (!isControllableWindowPane) { + addTwinMeshOutline( + THREE, + object, + outlineStyle, + outlineStyle.thresholdAngle ?? 60, + ); + } }); return report; } @@ -2082,6 +2346,553 @@ function carveReferencePassengerSeat(THREE, vehicle) { 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; @@ -2104,9 +2915,33 @@ function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) { const normalized = normalizeReferenceVehicle(THREE, vehicle); const driverCushion = measureReferenceDriverCushion(THREE, vehicle); const removedPassengerTriangles = carveReferencePassengerSeat(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; @@ -2118,6 +2953,15 @@ function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) { meshes: report.meshes, size: normalized.size.toArray().map((value) => Number(value.toFixed(3))), removedPassengerTriangles, + 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, })); resolve({ vehicle, report, normalized, driverCushion }); @@ -2203,17 +3047,30 @@ function initSeatTwin() { configureTwinSolidEntity(THREE, vehicleSystems.ambientMeshes, { renderOrder: 56, }); - configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes, { - transparent: false, - opacity: 1, - depthWrite: true, - renderOrder: 54, + 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), @@ -2223,8 +3080,9 @@ function initSeatTwin() { models.passenger.root.position.set(-0.62, 0.38, 0.25); models.passenger.root.userData.twinRole = "active_front_passenger"; configureTwinSolidEntity(THREE, models.passenger.root, { - renderOrder: 53, + renderOrder: 51, }); + configureActiveSeatDepthLayers(THREE, models.passenger); scene.add(models.passenger.root); const fixedModels = { @@ -2280,6 +3138,10 @@ function initSeatTwin() { dragging: false, pointer: { x: 0, y: 0 }, }; + applyTwinVehicleTheme(vehicleSystems); + window.addEventListener("cognitive-agent:demo-role-change", (event) => { + applyTwinVehicleTheme(vehicleSystems, event.detail?.role); + }); loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) .then(({ vehicle, report, normalized, driverCushion }) => { @@ -2500,6 +3362,7 @@ function initSeatTwin() { Math.cos(view.yaw) * horizontal, ); camera.lookAt(cameraTargetX, 1.14, -0.08); + updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera); positionTwinCallouts(); renderer.clear(); camera.layers.set(0); @@ -2866,6 +3729,22 @@ function populateModeChips() { : ""; button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n"); }); + + const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"]; + const allButtons = buttons(); + const dynamicButtons = dynamicModeOrder + .map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId)) + .filter(Boolean); + const staticButtons = allButtons.filter((button) => !dynamicButtons.includes(button)); + group.querySelector(".mode-group-divider")?.remove(); + dynamicButtons.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); + } + staticButtons.forEach((button) => group.append(button)); refreshIcons(); } @@ -3236,6 +4115,24 @@ function renderSeatState(actuatorState) { function renderDomainState(domainState) { if (!domainState) return; + const outletModeLabel = (value) => { + const normalized = String(value || "").toLowerCase().replaceAll("-", "_"); + 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 "开启"; + 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 active = climate.power === "on" || climate.ac === "on" || Number(climate.fan_level) > 0; @@ -3249,6 +4146,8 @@ function renderDomainState(domainState) { $("#climatePower").textContent = climate.power === "on" ? "ON" : (active ? "ACTIVE" : "OFF"); $("#driverTemperature").innerHTML = `${driverTemperature}℃`; $("#passengerTemperature").innerHTML = `${passengerTemperature}℃`; + $("#driverAirDirection").textContent = outletModeLabel(climate.zones?.driver?.outlet_mode); + $("#passengerAirDirection").textContent = outletModeLabel(climate.zones?.passenger?.outlet_mode); $("#airflowVisual").dataset.level = String(Math.max(0, Math.min(7, Number(climate.fan_level) || 0))); $("#fanLevel").textContent = `${climate.fan_level ?? 0} 档`; $("#cycleMode").textContent = climate.cycle_mode === "internal" ? "内循环" : "外循环"; @@ -3259,10 +4158,6 @@ function renderDomainState(domainState) { vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, Number(climate.fan_level) || 0)); vehicleSystems.targets.climateTemperature = Number(passengerTemperature) || 24; } - $('[data-twin-system="climate"]').classList.toggle("active", active); - $("#twinClimateState").textContent = active - ? `${passengerTemperature}℃ · ${climate.fan_level ?? 0}档` - : "关闭"; } const roof = domainState.roof; @@ -3275,21 +4170,14 @@ function renderDomainState(domainState) { ); $("#roofGlass").style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`; $("#roofShade").style.width = `${100 - shadePosition}%`; - $("#transmittanceValue").textContent = `${transmittance}%`; $("#shadePositionValue").textContent = `${shadePosition}%`; $("#automaticDimming").textContent = roof.automatic_dimming === "on" ? "开启" : "关闭"; - $("#roofMotorState").textContent = roof.fault - ? "故障" - : (roof.motion_state === "stopped" ? "已停止" : roof.motion_state); $("#roofState").textContent = roof.fault ? "FAULT" : "READY"; const vehicleSystems = state.seatTwin?.vehicleSystems; if (vehicleSystems) { vehicleSystems.targets.roofTransmittance = transmittance; vehicleSystems.targets.roofShadePosition = shadePosition; } - const roofActive = transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on"; - $('[data-twin-system="roof"]').classList.toggle("active", roofActive); - $("#twinRoofState").textContent = `透光 ${transmittance}% · 帘 ${shadePosition}%`; } const ambient = domainState.ambient_light; @@ -3317,9 +4205,6 @@ function renderDomainState(domainState) { line.style.boxShadow = `0 0 ${Math.round(5 + brightness / 8)}px ${color}`; line.style.opacity = String(opacity); }); - $("#ambientSwatch").style.background = color; - $("#ambientColor").textContent = color; - $("#ambientBrightness").textContent = `${brightness}%`; $("#ambientMode").textContent = modeLabels[ambient.mode] || ambient.mode || "自定义"; const vehicleSystems = state.seatTwin?.vehicleSystems; if (vehicleSystems) { @@ -3327,10 +4212,71 @@ function renderDomainState(domainState) { vehicleSystems.targets.ambientBrightness = brightness; vehicleSystems.colors.ambientTarget.set(color); } - $('[data-twin-system="ambient"]').classList.toggle("active", active); - $("#twinAmbientState").textContent = active - ? `${color} · ${brightness}%` - : "关闭"; + } + + const windowState = domainState.window || domainState.windows; + if (windowState) { + const readWindowPosition = (key) => { + const source = windowState.positions?.[key] + ?? windowState.windows?.[key] + ?? windowState[key]; + return percentageValue(source?.position_pct ?? source?.opening_pct ?? source?.position ?? source); + }; + const windowPositions = { + frontLeftWindow: readWindowPosition("front_left"), + frontRightWindow: readWindowPosition("front_right"), + rearLeftWindow: readWindowPosition("rear_left"), + rearRightWindow: readWindowPosition("rear_right"), + }; + Object.entries(windowPositions).forEach(([id, value]) => { + $(`#${id}`).textContent = `${value}%`; + }); + const anyWindowOpen = Object.values(windowPositions).some((value) => value > 0); + $('[data-domain-panel="window"]').classList.toggle("active", anyWindowOpen); + $("#windowState").textContent = anyWindowOpen ? "OPEN" : "CLOSED"; + const vehicleSystems = state.seatTwin?.vehicleSystems; + if (vehicleSystems) { + Object.entries(windowPositions).forEach(([id, value]) => { + const windowKey = { + frontLeftWindow: "front_left", + frontRightWindow: "front_right", + rearLeftWindow: "rear_left", + rearRightWindow: "rear_right", + }[id]; + vehicleSystems.targets.windowPositions[windowKey] = value; + }); + } + } + + 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]) => { + $(`#${id}`).textContent = onOffLabel(value); + }); + const anyLightOn = Object.values(lightStates).some((value) => onOffLabel(value) === "开启"); + $('[data-domain-panel="front_light"]').classList.toggle("active", anyLightOn); + $("#frontLightState").textContent = anyLightOn ? "ON" : "OFF"; + const vehicleSystems = state.seatTwin?.vehicleSystems; + if (vehicleSystems) { + vehicleSystems.targets.lowBeam = onOffLabel(lightStates.lowBeamState) === "开启" ? 1 : 0; + vehicleSystems.targets.highBeam = onOffLabel(lightStates.highBeamState) === "开启" ? 1 : 0; + vehicleSystems.targets.fogLight = onOffLabel(lightStates.fogLightState) === "开启" ? 1 : 0; + vehicleSystems.targets.turnSignal = onOffLabel(lightStates.turnSignalState) === "开启" ? 1 : 0; + } } } diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css new file mode 100644 index 0000000..986a0b2 --- /dev/null +++ b/apps/web-console/console-v2.css @@ -0,0 +1,2362 @@ +/* Desktop console refactor -------------------------------------------------- + This layer deliberately overrides the original console without changing the + tested control, gesture, hardware-safety, and digital-twin DOM contracts. */ + +.console-v2 { + --v2-accent: #0e7773; + --v2-accent-strong: #095d5a; + --v2-accent-soft: #d8f4f1; + --v2-sidebar: #d8f4f2; + --v2-sidebar-deep: #c7eeeb; + --v2-ink: #102427; + --v2-muted: #6f8082; + --v2-line: rgba(18, 85, 83, 0.14); + --v2-surface: #ffffff; + --v2-canvas: #f4f7f6; + --v2-shadow: 0 18px 50px rgba(35, 71, 74, 0.1); + min-width: 1180px; + min-height: 620px; + overflow: hidden; + color: var(--v2-ink); + background: #f4f7f6; + font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; +} + +.console-v2[data-demo-role="admin"] { + --v2-accent: #315eaa; + --v2-accent-strong: #234785; + --v2-accent-soft: #e1e9fa; + --v2-sidebar: #e0e8f7; + --v2-sidebar-deep: #cedbf2; + --v2-line: rgba(49, 94, 170, 0.16); +} + +.console-v2 * { + scrollbar-color: var(--v2-accent) rgba(133, 151, 151, 0.14); + scrollbar-width: thin; +} + +.console-v2 *::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.console-v2 *::-webkit-scrollbar-track { + background: rgba(133, 151, 151, 0.12); + border-radius: 999px; +} + +.console-v2 *::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--v2-accent) 74%, white); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} + +.console-v2 .v2-legacy-hidden, +.console-v2 .topbar, +.console-v2 .example-group, +.console-v2 #safetyStrip, +.console-v2 .gesture-backdrop, +.console-v2 .pipeline { + display: none !important; +} + +.console-v2 .workspace { + width: 100vw; + height: 100dvh; + min-height: 620px; + display: grid; + grid-template-columns: clamp(245px, 18.5vw, 310px) minmax(0, 1fr); + overflow: hidden; + opacity: 0; + background: var(--v2-canvas); + transition: opacity 180ms ease, grid-template-columns 180ms ease; +} + +.console-v2.v2-ready .workspace { + opacity: 1; +} + +.console-v2.v2-sidebar-collapsed .workspace { + grid-template-columns: 72px minmax(0, 1fr); +} + +/* Sidebar ----------------------------------------------------------------- */ + +.console-v2 .v2-sidebar { + min-width: 0; + height: 100dvh; + display: flex; + flex-direction: column; + gap: 0; + padding: clamp(20px, 2.5vh, 30px) clamp(18px, 2vw, 30px) 18px; + overflow: hidden; + color: var(--v2-ink); + background: + radial-gradient(circle at 14% 3%, rgba(255, 255, 255, 0.82), transparent 24%), + linear-gradient(158deg, var(--v2-sidebar) 0%, #e8f8f6 60%, var(--v2-sidebar-deep) 130%); + border-right: 1px solid var(--v2-line); +} + +.console-v2[data-demo-role="admin"] .v2-sidebar { + background: + radial-gradient(circle at 14% 3%, rgba(255, 255, 255, 0.8), transparent 24%), + linear-gradient(158deg, var(--v2-sidebar) 0%, #edf2fb 60%, var(--v2-sidebar-deep) 130%); +} + +.v2-profile { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) 30px; + align-items: center; + gap: 14px; + min-height: 66px; +} + +.v2-avatar { + position: relative; + width: 54px; + height: 54px; + display: grid; + place-items: center; + overflow: hidden; + background: + radial-gradient(circle at 28% 25%, rgba(255, 255, 255, 0.95), transparent 18%), + linear-gradient(135deg, #0b4d4b 3%, #70b7b1 49%, #d1efeb 52%, #276d69 100%); + border: 2px solid color-mix(in srgb, var(--v2-accent) 82%, #051617); + border-radius: 50%; + box-shadow: inset -10px -8px 18px rgba(0, 32, 33, 0.24), 0 8px 18px rgba(19, 74, 74, 0.14); +} + +.v2-avatar::after { + content: ""; + width: 74px; + height: 13px; + position: absolute; + transform: rotate(-42deg); + background: rgba(255, 255, 255, 0.26); + filter: blur(4px); +} + +[data-demo-role="admin"] .v2-avatar { + background: + radial-gradient(circle at 28% 25%, rgba(255, 255, 255, 0.95), transparent 18%), + linear-gradient(135deg, #183c7a 3%, #6688d4 48%, #e5b0c8 55%, #315eaa 100%); +} + +.v2-profile-copy { + min-width: 0; +} + +.v2-profile-copy > small { + display: block; + margin-bottom: 4px; + color: color-mix(in srgb, var(--v2-accent) 62%, #506366); + font-size: 8px; + font-weight: 900; + letter-spacing: 0.14em; +} + +.v2-role-select { + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + cursor: pointer; +} + +.v2-role-select > span, +.v2-role-select select, +.v2-role-select .v2-dropdown-trigger { + color: #091c1e; + font-size: clamp(20px, 1.6vw, 26px); + font-weight: 900; + letter-spacing: -0.04em; +} + +.v2-role-select select { + max-width: 118px; + padding: 0 17px 0 0; + appearance: none; + background: transparent; + border: 0; + outline: 0; + cursor: pointer; +} + +.v2-role-select > svg { + width: 16px; + height: 16px; + margin-left: -20px; + color: var(--v2-accent); + pointer-events: none; +} + +.v2-sidebar-toggle { + width: 29px; + height: 29px; + display: grid; + place-items: center; + padding: 0; + color: var(--v2-accent-strong); + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(255, 255, 255, 0.82); + border-radius: 8px; + cursor: pointer; + transition: 150ms ease; +} + +.v2-sidebar-toggle:hover { + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 6px 16px rgba(35, 85, 85, 0.1); +} + +.v2-sidebar-toggle svg { + width: 15px; + height: 15px; +} + +.console-v2.v2-sidebar-collapsed .v2-sidebar { + align-items: center; + padding: 15px 9px; +} + +.console-v2.v2-sidebar-collapsed .v2-profile { + width: 100%; + min-height: 104px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.console-v2.v2-sidebar-collapsed .v2-avatar { + width: 44px; + height: 44px; +} + +.console-v2.v2-sidebar-collapsed .v2-profile-copy, +.console-v2.v2-sidebar-collapsed .v2-sidebar-section, +.console-v2.v2-sidebar-collapsed .v2-sidebar-footer { + display: none; +} + +.v2-sidebar-section { + min-width: 0; +} + +.v2-control-section { + margin-top: clamp(22px, 3.5vh, 42px); +} + +.v2-section-heading span, +.v2-operation-heading .section-kicker, +.v2-output-heading small { + color: var(--v2-accent); + font-size: 8px; + font-weight: 900; + letter-spacing: 0.15em; +} + +.v2-section-heading h2 { + margin: 3px 0 clamp(18px, 2.3vh, 26px); + color: var(--v2-accent-strong); + font-size: clamp(25px, 2vw, 31px); + font-weight: 900; + letter-spacing: -0.04em; +} + +.v2-control-section > .v2-field-caption, +.v2-config-section > .v2-field-caption { + display: block; + margin: 0 0 8px; + color: #788888; + font-size: 13px; + font-weight: 750; +} + +.console-v2 .v2-config-field > .field-label, +.console-v2 .v2-config-field > label.field-label { + display: block; + margin: 0 0 6px; + color: #8a9999; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.v2-control-switch { + display: grid; + grid-template-columns: 1fr; + gap: 7px; +} + +.v2-control-switch button { + min-height: 52px; + display: grid; + grid-template-columns: 30px 1fr; + align-items: center; + gap: 8px; + padding: 0 15px; + color: #637677; + background: transparent; + border: 1px solid transparent; + border-radius: 13px; + text-align: left; + font-size: 15px; + font-weight: 700; + cursor: pointer; + transition: 160ms ease; +} + +.v2-control-switch button svg { + width: 19px; + height: 19px; +} + +.v2-control-switch button:hover { + color: var(--v2-accent-strong); + background: rgba(255, 255, 255, 0.5); +} + +.v2-control-switch button.active { + color: #111f20; + background: rgba(255, 255, 255, 0.94); + border-color: rgba(255, 255, 255, 0.86); + box-shadow: 0 10px 28px rgba(34, 93, 92, 0.08); +} + +.v2-control-switch button.active svg { + color: var(--v2-accent); +} + +.v2-config-section { + margin-top: clamp(18px, 2.5vh, 28px); +} + +.v2-divider { + height: 1px; + margin-bottom: 15px; + background: linear-gradient(90deg, var(--v2-accent), transparent); + opacity: 0.32; +} + +.v2-config-stack { + display: grid; + gap: 12px; + padding: 2px 0 0 10px; + border-left: 1px solid color-mix(in srgb, var(--v2-accent) 14%, transparent); +} + +.console-v2 .v2-config-field { + min-width: 0; + margin: 0; +} + +.console-v2 .v2-config-field .provider-note { + margin: 5px 3px 0; + color: #7a8a8a; + font-size: 8px; + line-height: 1.35; +} + +.console-v2 .v2-gesture-model-note { + display: block; + margin: 5px 3px 0; + color: #7a8a8a; + font-size: 8px; + line-height: 1.35; +} + +.console-v2 .v2-vehicle-field .vehicle-select-control, +.v2-static-select, +.v2-select-shell { + min-height: 46px; + display: grid; + grid-template-columns: 22px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 0 13px; + color: #0d2527; + background: color-mix(in srgb, var(--v2-accent-soft) 72%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 17%, white); + border-radius: 10px; +} + +.console-v2 .v2-vehicle-field .vehicle-select-control > svg, +.v2-static-select > svg, +.v2-select-shell > svg { + width: 17px; + height: 17px; + color: var(--v2-accent); +} + +.console-v2 .v2-vehicle-field select, +.v2-select-shell select { + width: 100%; + height: 44px; + padding: 0; + appearance: none; + color: #102224; + background: transparent; + border: 0; + outline: 0; + font-size: 14px; + font-weight: 850; + cursor: pointer; +} + +/* Custom popover menus keep the original closed-field styling intact. */ + +.v2-custom-select { + position: relative; + overflow: visible; +} + +.v2-custom-select.v2-dropdown-open { + z-index: 80; +} + +.v2-native-select { + width: 1px !important; + height: 1px !important; + position: absolute !important; + inset: auto !important; + overflow: hidden !important; + padding: 0 !important; + clip-path: inset(50%) !important; + opacity: 0 !important; + pointer-events: none !important; +} + +.v2-dropdown-trigger { + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0; + color: inherit; + background: transparent; + border: 0; + outline: 0; + cursor: pointer; +} + +.vehicle-select-control > .v2-dropdown-trigger, +.v2-select-shell > .v2-dropdown-trigger { + width: 100%; + height: 44px; + grid-column: 2 / -1; + font-size: 14px; + font-weight: 850; + text-align: left; +} + +.v2-role-select > .v2-dropdown-trigger { + width: auto; + max-width: 118px; +} + +.v2-role-select > .v2-dropdown-trigger:focus-visible { + box-shadow: none; +} + +.v2-dropdown-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-dropdown-caret { + width: 0; + height: 0; + flex: 0 0 auto; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + border-top: 6px solid var(--v2-accent-strong); + transition: transform 150ms ease; +} + +.v2-dropdown-open .v2-dropdown-caret { + transform: rotate(180deg); +} + +.v2-dropdown-trigger:focus-visible { + border-radius: 6px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--v2-accent) 16%, transparent); +} + +.v2-dropdown-menu { + min-width: 100%; + position: absolute; + top: calc(100% + 7px); + right: 0; + left: 0; + z-index: 100; + display: grid; + gap: 3px; + padding: 6px; + overflow: hidden; + background: rgba(255, 255, 255, 0.98); + border: 1px solid color-mix(in srgb, var(--v2-accent) 12%, #eef3f2); + border-radius: 13px; + box-shadow: 0 16px 34px rgba(27, 67, 69, 0.18); + backdrop-filter: blur(14px); +} + +.v2-dropdown-menu[hidden] { + display: none; +} + +.v2-role-select .v2-dropdown-menu { + width: 126px; + min-width: 126px; + right: auto; + left: 24px; +} + +.v2-dropdown-option { + width: 100%; + min-height: 34px; + display: grid; + grid-template-columns: minmax(0, 1fr) 14px; + align-items: center; + gap: 8px; + padding: 7px 10px; + color: #405456; + background: transparent; + border: 0; + border-radius: 8px; + text-align: left; + font-size: 11px; + font-weight: 800; + cursor: pointer; + transition: 130ms ease; +} + +.v2-role-select .v2-dropdown-option { + font-size: 13px; +} + +.v2-dropdown-option:hover, +.v2-dropdown-option:focus-visible { + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 66%, white); + outline: 0; +} + +.v2-dropdown-option[aria-selected="true"] { + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 78%, white); +} + +.v2-dropdown-option[aria-selected="true"]::after { + content: "✓"; + justify-self: end; + color: var(--v2-accent); + font-size: 12px; + font-weight: 950; +} + +.v2-dropdown-option:disabled { + color: #a9b3b3; + background: transparent; + cursor: not-allowed; + opacity: 0.72; +} + +.v2-static-select strong { + min-width: 0; + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-field-state { + padding: 4px 6px; + color: var(--v2-accent-strong); + background: rgba(255, 255, 255, 0.68); + border-radius: 5px; + font: 850 8px "SFMono-Regular", Consolas, monospace; +} + +.v2-static-select.unavailable { + color: #9b5f32; + background: #fff1df; +} + +.console-v2[data-demo-role="user"] .v2-execution-field { + display: none; +} + +.v2-user-target-status { + min-height: 33px; + display: none; + grid-template-columns: 8px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 7px 11px; + color: #516f70; + background: rgba(255, 255, 255, 0.48); + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 9px; + font-size: 9px; + font-weight: 800; +} + +.console-v2[data-demo-role="user"] .v2-user-target-status { + display: grid; +} + +.v2-user-target-status > i { + width: 7px; + height: 7px; + background: #d69549; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(214, 149, 73, 0.12); +} + +.v2-user-target-status[data-state="ready"] > i { + background: #18a493; + box-shadow: 0 0 0 4px rgba(24, 164, 147, 0.12); +} + +.console-v2 .v2-provider-field, +.console-v2 .v2-native-execution-field { + display: none !important; +} + +.v2-advanced-stack { + display: grid; + gap: 8px; +} + +.console-v2 .v2-advanced-field:not([hidden]) { + display: block; + padding: 10px; + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(255, 255, 255, 0.74); + border-radius: 10px; +} + +.console-v2 .v2-advanced-field input, +.console-v2 .v2-advanced-field select { + width: 100%; + min-height: 36px; + margin-top: 4px; + padding: 0 9px; + background: rgba(255, 255, 255, 0.94); + border: 1px solid var(--v2-line); + border-radius: 7px; + font-size: 10px; +} + +.console-v2 .v2-advanced-field button { + min-height: 33px; + border-radius: 7px; +} + +.v2-sidebar-footer { + min-height: 50px; + display: grid; + grid-template-columns: 10px minmax(0, 1fr) 34px; + align-items: center; + gap: 10px; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--v2-line); +} + +.v2-live-dot { + width: 8px; + height: 8px; + background: #18a493; + border: 2px solid rgba(255, 255, 255, 0.9); + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(24, 164, 147, 0.12); +} + +.v2-sidebar-footer.offline .v2-live-dot { + background: #dc9343; + box-shadow: 0 0 0 4px rgba(220, 147, 67, 0.12); +} + +.v2-sidebar-footer strong, +.v2-sidebar-footer small { + display: block; +} + +.v2-sidebar-footer strong { + color: #284244; + font-size: 10px; +} + +.v2-sidebar-footer small { + margin-top: 2px; + overflow: hidden; + color: #7b8e8e; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-sidebar-footer button { + width: 32px; + height: 32px; + display: grid; + place-items: center; + color: #708486; + background: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(255, 255, 255, 0.72); + border-radius: 8px; + cursor: pointer; +} + +.v2-sidebar-footer button svg { + width: 14px; + height: 14px; +} + +/* Main canvas -------------------------------------------------------------- */ + +.console-v2 .main-panel { + min-width: 0; + height: 100dvh; + display: grid; + grid-template-rows: clamp(240px, 31.5dvh, 340px) minmax(0, 1fr); + gap: clamp(10px, 1.25vh, 14px); + padding: clamp(12px, 1.5vw, 20px); + overflow: hidden; + background: + radial-gradient(circle at 40% -15%, rgba(255, 255, 255, 0.98), transparent 38%), + #f5f7f6; +} + +.v2-operation-shell, +.v2-output-shell { + min-width: 0; + min-height: 0; + position: relative; + overflow: hidden; + background: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(117, 145, 145, 0.14); + border-radius: clamp(17px, 1.4vw, 24px); + box-shadow: var(--v2-shadow); +} + +.v2-operation-shell { + isolation: isolate; +} + +.v2-operation-glow { + width: 280px; + height: 280px; + position: absolute; + top: -190px; + left: 45%; + z-index: -1; + background: var(--v2-accent-soft); + border-radius: 50%; + filter: blur(44px); + opacity: 0.62; +} + +.v2-natural-panel, +.v2-gesture-panel { + width: 100%; + height: 100%; +} + +.v2-natural-panel { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(118px, 1fr) auto; + gap: 8px; + padding: clamp(18px, 2vh, 24px) clamp(22px, 2.3vw, 34px) 15px; +} + +.v2-operation-heading { + align-self: start; + min-width: 0; + margin: 0; + padding: 0; +} + +.console-v2 .v2-operation-heading h2 { + max-width: none; + margin: 4px 0 3px; + color: #101f21; + font-size: clamp(23px, 2.05vw, 34px); + font-weight: 950; + line-height: 1.08; + letter-spacing: -0.045em; +} + +.console-v2 .v2-operation-heading .panel-intro { + max-width: 560px; + margin: 0; + color: #8b9899; + font-size: clamp(10px, 0.86vw, 13px); + line-height: 1.55; +} + +.v2-natural-grid { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(170px, 17vw, 230px); + align-items: center; + gap: 14px; +} + +.console-v2 .v2-request-form { + min-width: 0; + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 1fr; + gap: 0; + align-items: stretch; + margin: 0; +} + +.console-v2 .v2-request-form .field-label-row { + grid-column: 1 / -1; + height: 0; + min-height: 0; + margin: 0; +} + +.console-v2 .v2-request-form .field-label-row label { + display: none; +} + +.console-v2 .v2-request-form .context-reset { + width: 24px; + height: 24px; + position: absolute; + top: 8px; + right: 8px; + z-index: 4; + color: var(--v2-accent); + background: transparent; + border: 0; +} + +.console-v2 .v2-request-form .command-composer { + height: 118px; + min-height: 118px; + margin: 0; + overflow: hidden; + background: #fff; + border: 1px solid rgba(104, 136, 136, 0.18); + border-radius: 18px; + box-shadow: 0 12px 35px rgba(40, 76, 77, 0.08); +} + +.console-v2 .v2-request-form #utterance { + height: 116px; + min-height: 116px; + padding: 18px 72px 28px 20px; + color: #23383a; + background: transparent; + border: 0; + font-size: clamp(13px, 1.05vw, 16px); + line-height: 1.5; + resize: none; +} + +.console-v2 .v2-request-form #utterance:focus { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--v2-accent) 34%, white); +} + +.console-v2 .v2-request-form .composer-hint { + right: 62px; + bottom: 9px; + color: #a0acad; + font-size: 8px; +} + +.console-v2 .v2-request-form .primary-action { + width: 36px; + height: 36px; + min-height: 36px; + position: absolute; + right: 10px; + bottom: 10px; + z-index: 3; + display: grid; + place-items: center; + margin: 0; + padding: 0; + color: #fff; + background: linear-gradient(145deg, var(--v2-accent), var(--v2-accent-strong)); + border: 0; + border-radius: 50%; + box-shadow: 0 7px 16px color-mix(in srgb, var(--v2-accent) 21%, transparent); +} + +.console-v2 .v2-request-form .primary-action span { + display: none; +} + +.console-v2 .v2-request-form .primary-action svg { + width: 17px; + height: 17px; +} + +.console-v2 .v2-request-status { + min-width: 0; + height: 118px; + min-height: 118px; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + gap: 7px; + margin: 0; + padding: 16px; + color: #526465; + background: #f2f4f3; + border: 1px solid #ebeeee; + border-radius: 17px; +} + +.console-v2 .v2-request-status .section-kicker, +.console-v2 .v2-request-status .matrix-stat { + display: none; +} + +.console-v2 .v2-request-status .request-title-row { + display: grid; + gap: 7px; +} + +.console-v2 .v2-request-status h2 { + margin: 0; + overflow: hidden; + color: #425557; + font-size: 13px; + font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .v2-request-status .run-status { + width: fit-content; + margin: 0; + padding: 4px 7px; + font-size: 7px; +} + +.console-v2 .v2-request-status #requestMeta { + margin: 0; + overflow: hidden; + color: #899697; + font: 700 8px "SFMono-Regular", Consolas, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .v2-mode-rail { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: center; + gap: 0; + margin: 0; + padding: 0; + background: transparent; + border: 0; +} + +.console-v2 .v2-mode-rail .mode-rail-heading { + display: none; +} + +.console-v2 .v2-mode-rail .mode-rail-heading > span { + color: #8a9797; + font-size: 9px; + font-weight: 800; + white-space: nowrap; +} + +.console-v2 .v2-mode-rail #saveModeToggle { + display: none; +} + +.console-v2 .v2-mode-rail .mode-chips { + min-width: 0; + display: flex; + gap: 8px; + padding: 2px; + overflow-x: auto; + scrollbar-width: none; +} + +.console-v2 .v2-mode-rail .mode-chips::-webkit-scrollbar { + display: none; +} + +.console-v2 .v2-mode-rail .mode-group-divider { + width: 1px; + height: 24px; + flex: 0 0 1px; + align-self: center; + margin: 0 4px; + background: rgba(21, 54, 56, 0.24); + border-radius: 999px; +} + +.console-v2 .v2-mode-rail .mode-chips button { + min-width: clamp(92px, 9.5vw, 145px); + min-height: 34px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 13px; + color: #153638; + background: color-mix(in srgb, var(--v2-accent-soft) 80%, white); + border: 1px solid color-mix(in srgb, var(--v2-accent) 10%, white); + border-radius: 999px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.console-v2 .v2-mode-rail .mode-chips button.v2-edge-fade-left { + -webkit-mask-image: linear-gradient( + 90deg, + transparent var(--v2-edge-fade-start), + #000 var(--v2-edge-fade-end) + ); + mask-image: linear-gradient( + 90deg, + transparent var(--v2-edge-fade-start), + #000 var(--v2-edge-fade-end) + ); +} + +.console-v2 .v2-mode-rail .mode-chips button.v2-edge-fade-right { + -webkit-mask-image: linear-gradient( + 90deg, + #000 var(--v2-edge-fade-start), + transparent var(--v2-edge-fade-end) + ); + mask-image: linear-gradient( + 90deg, + #000 var(--v2-edge-fade-start), + transparent var(--v2-edge-fade-end) + ); +} + +.console-v2 .v2-mode-rail .mode-chips button svg { + width: 13px; + height: 13px; + color: var(--v2-accent); +} + +.console-v2 .v2-mode-rail .mode-cycle-badge { + display: none; +} + +.console-v2 .v2-mode-rail .save-mode-composer { + position: absolute; + right: 24px; + bottom: 12px; + z-index: 5; +} + +.console-v2[data-control-mode="gesture"] .v2-natural-panel, +.console-v2:not([data-control-mode="gesture"]) .v2-gesture-panel, +.console-v2:not([data-control-mode="gesture"]) .v2-gesture-model-field { + display: none; +} + +/* Inline gesture control -------------------------------------------------- */ + +.console-v2 .v2-gesture-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 8px; + padding: clamp(18px, 2vh, 24px) clamp(22px, 2.3vw, 34px) 15px; +} + +.console-v2 .v2-inline-gesture, +.console-v2 .v2-inline-gesture.open { + width: 100%; + height: 100%; + position: static; + display: grid; + grid-template-columns: + minmax(205px, 0.72fr) + minmax(330px, 1.12fr) + minmax(282px, 0.9fr); + grid-template-rows: auto auto minmax(0, 1fr); + gap: 9px 12px; + padding: 0; + overflow: hidden; + visibility: visible; + transform: none; + color: var(--v2-ink); + background: transparent; + border: 0; + box-shadow: none; +} + +.console-v2 .v2-inline-gesture .gesture-drawer-header { + display: none; +} + +.console-v2 .v2-inline-gesture .gesture-close { + display: none; +} + +.console-v2 .v2-inline-gesture .gesture-camera-stage { + grid-column: 1; + grid-row: 1 / -1; + height: 100%; + min-height: 0; + margin: 0; + overflow: hidden; + background: linear-gradient(145deg, #263537, #111b1d); + border: 1px solid rgba(43, 103, 102, 0.18); + border-radius: 17px; + box-shadow: 0 12px 32px rgba(25, 48, 50, 0.15); +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle { + width: 42px; + height: 42px; + min-height: 42px; + position: absolute; + right: 13px; + bottom: 12px; + z-index: 5; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + color: #083a35; + border: 1px solid rgba(255, 255, 255, 0.56); + border-radius: 50%; + background: linear-gradient(135deg, #a8f2e2, #58d2b9); + box-shadow: 0 8px 20px rgba(4, 27, 27, 0.3); + cursor: pointer; + transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(4, 27, 27, 0.38); +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle:disabled { + cursor: wait; + opacity: 0.64; +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle svg { + width: 19px; + height: 19px; +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle span { + width: 1px; + height: 1px; + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle.active { + color: #fff4f4; + border-color: rgba(255, 255, 255, 0.34); + background: linear-gradient(135deg, #e26969, #bb3f48); +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle.active svg { + display: none; +} + +.console-v2 .v2-inline-gesture .v2-camera-toggle.active::before { + width: 13px; + height: 13px; + content: ""; + border-radius: 3px; + background: currentColor; +} + +.console-v2 .v2-inline-gesture .gesture-status-strip { + grid-column: 2; + grid-row: 1; + min-height: 36px; + margin: 0; + padding: 7px 10px; + background: #f1f5f4; + border: 1px solid #e2eae8; + border-radius: 9px; +} + +.console-v2 .v2-inline-gesture .gesture-now { + grid-column: 2; + grid-row: 2; + min-height: 50px; + margin: 0; + padding: 8px 10px; + background: color-mix(in srgb, var(--v2-accent-soft) 44%, white); + border: 1px solid var(--v2-line); + border-radius: 10px; +} + +.console-v2 .v2-inline-gesture .gesture-actions { + grid-column: 2; + grid-row: 3; + align-self: end; + margin: 0; +} + +.console-v2 .v2-inline-gesture #gestureStopButton, +.console-v2[data-demo-role="user"] .v2-inline-gesture .gesture-actions { + display: none; +} + +.console-v2[data-demo-role="admin"] .v2-inline-gesture .gesture-actions { + position: relative; + z-index: 8; + display: flex; + justify-content: flex-end; +} + +.console-v2[data-demo-role="admin"] .v2-inline-gesture #gestureArmButton { + width: min(150px, 100%); +} + +.console-v2 .v2-inline-gesture .gesture-debug-panel { + width: min(360px, 100%); + position: absolute; + right: 0; + bottom: calc(100% + 7px); + display: grid; + gap: 7px; + padding: 9px; + color: var(--v2-ink); + border: 1px solid var(--v2-line); + border-radius: 11px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 14px 32px rgba(25, 48, 50, 0.2); +} + +.console-v2 .v2-inline-gesture .gesture-debug-panel[hidden] { + display: none; +} + +.console-v2 .v2-inline-gesture .gesture-debug-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.console-v2 .v2-inline-gesture .gesture-debug-heading span { + color: var(--v2-accent-strong); + font-size: 11px; + font-weight: 900; +} + +.console-v2 .v2-inline-gesture .gesture-debug-heading small { + color: var(--v2-muted); + font-size: 8px; +} + +.console-v2 .v2-inline-gesture .gesture-debug-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; +} + +.console-v2 .v2-inline-gesture .gesture-debug-controls select, +.console-v2 .v2-inline-gesture .gesture-debug-controls button { + min-height: 32px; + border: 1px solid var(--v2-line); + border-radius: 8px; + font-size: 9px; +} + +.console-v2 .v2-inline-gesture .gesture-debug-controls select { + min-width: 0; + padding: 0 8px; + color: var(--v2-ink); + background: #f7f9f9; +} + +.console-v2 .v2-inline-gesture .gesture-debug-controls button { + padding: 5px 9px; + color: white; + background: var(--v2-accent); +} + +.console-v2 .v2-inline-gesture .gesture-debug-controls button svg { + width: 12px; + height: 12px; +} + +.console-v2 .v2-inline-gesture .gesture-actions button { + min-height: 38px; + border-radius: 9px; +} + +.console-v2 .v2-inline-gesture .gesture-footnote, +.console-v2 .v2-inline-gesture .gesture-progress { + display: none; +} + +.console-v2 .v2-inline-gesture .gesture-guide-card { + min-width: 0; + min-height: 0; + grid-column: 3; + grid-row: 1 / -1; + align-self: stretch; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + padding: 9px 11px; + overflow: auto; + border: 1px solid var(--v2-line); + border-radius: 13px; + background: color-mix(in srgb, var(--v2-accent-soft) 35%, white); +} + +.console-v2 .v2-inline-gesture .gesture-card-heading { + min-width: 0; + gap: 8px; + margin: 0 0 6px; +} + +.console-v2 .v2-inline-gesture .gesture-card-heading > div { + min-width: 0; +} + +.console-v2 .v2-inline-gesture .gesture-card-heading span { + color: #173b3a; + font-size: 12px; +} + +.console-v2 .v2-inline-gesture .gesture-card-heading small { + overflow: hidden; + color: #718987; + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .v2-inline-gesture .gesture-card-heading button { + flex: 0 0 auto; + min-height: 26px; + padding: 4px 7px; + color: var(--v2-accent-strong); + border: 1px solid color-mix(in srgb, var(--v2-accent) 18%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--v2-accent-soft) 62%, white); + font-size: 8px; +} + +.console-v2 .v2-inline-gesture .gesture-card-heading button svg { + width: 11px; + height: 11px; +} + +.console-v2 .v2-inline-gesture .gesture-map-grid { + min-height: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: stretch; + gap: 4px 6px; +} + +.console-v2 .v2-inline-gesture .gesture-map-grid > div { + min-height: 0; + padding: 3px 7px; + color: #637c79; + border: 1px solid rgba(43, 103, 102, 0.08); + border-radius: 7px; + background: rgba(255, 255, 255, 0.72); + font-size: 8px; +} + +.console-v2 .v2-inline-gesture .gesture-map-grid i { + color: #8fa6a3; +} + +.console-v2 .v2-inline-gesture .gesture-map-grid b { + color: var(--v2-accent-strong); +} + +.console-v2 .v2-inline-gesture .gesture-customize { + margin-top: 7px; + padding-top: 7px; + border-color: var(--v2-line); +} + +.console-v2 .v2-inline-gesture .gesture-guide-card:has(.gesture-customize:not([hidden])) .gesture-map-grid { + display: none; +} + +.console-v2 .v2-inline-gesture .gesture-customize > div span { + color: #718987; +} + +.console-v2 .v2-inline-gesture .gesture-customize > div strong { + color: #173b3a; +} + +.console-v2 .v2-inline-gesture .gesture-customize select { + color: #173b3a; + border-color: var(--v2-line); + background: white; +} + +.console-v2.gesture-open { + overflow: hidden; +} + +/* Output pages ------------------------------------------------------------ */ + +.v2-output-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(92px, 8vw, 124px); + grid-template-rows: 56px minmax(0, 1fr); + gap: 0; + padding: 0; +} + +.console-v2[data-demo-role="user"] .v2-output-shell { + grid-template-columns: minmax(0, 1fr); +} + +.v2-output-shell.v2-twin-active { + grid-template-rows: minmax(0, 1fr); +} + +.v2-output-shell.v2-twin-active .v2-output-heading { + display: none; +} + +.v2-output-shell.v2-twin-active .v2-output-content { + grid-row: 1; + margin-top: 9px; +} + +.v2-output-heading { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 18px 8px 22px; + border-bottom: 1px solid rgba(114, 140, 140, 0.12); +} + +.v2-output-heading > div { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.v2-output-heading > div > div { + min-width: 0; + display: flex; + align-items: center; +} + +.v2-page-index { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--v2-accent); + font: 900 23px/1 "SFMono-Regular", Consolas, monospace; + letter-spacing: -0.08em; +} + +.console-v2[data-demo-role="user"] .v2-page-index { + display: none; +} + +.v2-output-heading h2 { + margin: 0; + color: var(--v2-accent-strong); + font: 900 clamp(16px, 1.4vw, 21px)/1 Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + letter-spacing: -0.025em; +} + +.v2-output-heading small { + display: none; +} + +.v2-output-state { + display: inline-flex; + align-items: center; + gap: 7px; + color: #718080; + font: 850 8px "SFMono-Regular", Consolas, monospace; + white-space: nowrap; +} + +.v2-output-state i { + width: 7px; + height: 7px; + background: var(--v2-accent); + border-radius: 50%; + box-shadow: 0 0 0 5px color-mix(in srgb, var(--v2-accent) 10%, transparent); +} + +.v2-output-content { + min-width: 0; + min-height: 0; + position: relative; + grid-column: 1; + grid-row: 2; + margin: 5px 13px 13px; + overflow: hidden; + background: #f2f4f3; + border: 1px solid #edf0ef; + border-radius: 17px; +} + +.console-v2 .v2-output-page { + position: absolute; + inset: 0; + display: none !important; + margin: 0; + overflow: auto; +} + +.console-v2 .v2-output-page.v2-active-output { + display: block !important; +} + +.console-v2 .result-workspace { + display: contents !important; +} + +.console-v2 .result-workspace > .result-workspace-heading, +.console-v2 .result-workspace > .result-nav { + display: none !important; +} + +.console-v2 .result-workspace .tab-panel { + min-height: 100%; + padding: clamp(16px, 1.6vw, 24px); + background: #f7f9f8; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.console-v2 .result-workspace .tab-panel.v2-scrollable::after { + content: "向下滚动查看更多"; + display: block; + width: fit-content; + margin: 18px auto 0; + padding: 5px 9px; + color: #869696; + background: #edf2f1; + border-radius: 999px; + font-size: 8px; +} + +.console-v2 .joint-twin-shell.v2-output-page { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); + gap: 10px; + padding: 10px; + overflow: hidden; + background: #eef2f1; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.console-v2 .joint-twin-shell.v2-output-page.v2-active-output { + display: grid !important; +} + +.console-v2[data-demo-role="user"] .v2-output-shell.v2-twin-active { + overflow: visible; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.console-v2[data-demo-role="user"] .v2-output-shell.v2-twin-active .v2-output-content { + margin: 0; + background: transparent; + border: 0; + border-radius: 0; +} + +.console-v2[data-demo-role="user"] .joint-twin-shell.v2-output-page { + gap: 12px; + padding: 0; + background: transparent; +} + +.console-v2 .joint-twin-shell > .joint-twin-heading, +.console-v2 .joint-twin-shell > .joint-hardware-boundary { + display: none !important; +} + +.console-v2 .joint-twin-shell > .twin-band, +.console-v2 .joint-twin-shell > .cabin-band { + min-width: 0; + height: 100%; + display: grid; + margin: 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(113, 141, 140, 0.14); + border-radius: 12px; +} + +.console-v2 .joint-twin-shell > .twin-band { + grid-template-rows: 38px minmax(0, 1fr); +} + +.console-v2 .joint-twin-shell > .cabin-band { + grid-template-rows: 38px minmax(0, 1fr); + padding: 0; + position: relative; +} + +.console-v2 .joint-twin-shell .twin-heading { + min-height: 0; + padding: 4px 8px 4px 10px; + background: rgba(255, 255, 255, 0.6); + border-bottom: 1px solid rgba(113, 141, 140, 0.12); +} + +.console-v2 .joint-twin-shell .twin-heading .section-kicker { + display: block; + font-size: 6px; +} + +.console-v2 .joint-twin-shell .twin-heading h3 { + display: block; + margin: 0; + font-size: 13px; +} + +.console-v2 .joint-twin-shell .twin-toolbar { + width: auto; + justify-content: flex-end; +} + +.console-v2 .joint-twin-shell .twin-focus-badge { + min-height: 24px; + padding: 3px 6px; +} + +.console-v2 .joint-twin-shell .twin-reset { + width: 27px; + height: 27px; + min-height: 27px; +} + +.console-v2 .joint-twin-shell .twin-stage { + width: 100%; + height: 100% !important; + min-height: 0; + background: + radial-gradient(ellipse at 55% 82%, rgba(62, 119, 115, 0.13), transparent 38%), + linear-gradient(165deg, #f0f5f3 0%, #e3ebe8 100%); +} + +.console-v2 .joint-twin-shell #seatTwinCanvas { + width: 100% !important; + height: 100% !important; +} + +.console-v2 .joint-twin-shell .twin-readout { + display: none; +} + +.console-v2 .joint-twin-shell .vehicle-system-hud { + top: 12px; + left: 12px; + transform: scale(0.88); + transform-origin: top left; +} + +.console-v2 .joint-twin-shell .cabin-heading { + min-height: 0; + display: flex; + align-items: center; + margin: 0; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.62); + border-bottom: 1px solid rgba(113, 141, 140, 0.12); +} + +.console-v2 .joint-twin-shell .cabin-heading .section-kicker { + font-size: 6px; +} + +.console-v2 .joint-twin-shell .cabin-heading h3 { + margin: 0; + font-size: 13px; +} + +.console-v2 .joint-twin-shell .cabin-heading .simulation-label { + display: none; +} + +.console-v2 .joint-twin-shell .domain-grid { + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: none; + grid-auto-rows: max-content; + align-content: start; + gap: 4px; + padding: 4px; + overflow-x: hidden; + overflow-y: hidden; +} + +.console-v2 .joint-twin-shell .domain-panel { + min-width: 0; + min-height: 0; + padding: 6px; + overflow: hidden; + border-radius: 8px; + box-shadow: none; +} + +.console-v2 .joint-twin-shell .domain-panel header { + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: 4px; +} + +.console-v2 .joint-twin-shell .domain-icon { + width: 24px; + height: 24px; + color: var(--v2-accent-strong); + background: var(--v2-accent-soft); + border: 1px solid color-mix(in srgb, var(--v2-accent) 16%, transparent); +} + +.console-v2 .joint-twin-shell .domain-icon svg { + width: 13px; + height: 13px; + color: inherit; + stroke-width: 1.9; +} + +.console-v2 .joint-twin-shell .domain-panel header strong { + font-size: 12px; +} + +.console-v2 .joint-twin-shell .domain-panel header small { + font-size: 7.5px; +} + +.console-v2 .joint-twin-shell .device-state { + padding: 3px 5px; + font-size: 9px; +} + +.console-v2 .joint-twin-shell .climate-display, +.console-v2 .joint-twin-shell .roof-visual, +.console-v2 .joint-twin-shell .ambient-visual { + min-height: 34px; + margin-top: 4px; +} + +.console-v2 .joint-twin-shell .climate-display { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + padding: 0; +} + +.console-v2 .joint-twin-shell .climate-direction-display { + margin-top: 3px; +} + +.console-v2 .joint-twin-shell .climate-direction-display .zone-temperature { + background: rgba(243, 245, 244, 0.7); + border-top: 1px solid rgba(113, 141, 140, 0.13); +} + +.console-v2 .joint-twin-shell .zone-temperature { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 5px 7px; + text-align: left; +} + +.console-v2 .joint-twin-shell .zone-temperature span { + color: #243d40; + font-size: 12px; + font-weight: 750; +} + +.console-v2 .joint-twin-shell .zone-temperature strong { + margin: 0 0 0 auto; + font: 900 13px/1 Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + text-align: right; +} + +.console-v2 .joint-twin-shell .airflow-visual { + display: none; +} + +.console-v2 .joint-twin-shell .roof-visual { + padding: 3px 7px; +} + +.console-v2 .joint-twin-shell .roof-glass { + height: 28px; +} + +.console-v2 .joint-twin-shell .ambient-dashboard-sketch { + height: 28px; + top: 3px; +} + +.console-v2 .joint-twin-shell .ambient-location-label { + display: none; +} + +.console-v2 .joint-twin-shell .domain-readouts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + margin-top: 5px; +} + +.console-v2 .joint-twin-shell .domain-readouts > div { + min-width: 0; + min-height: 29px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 5px 7px; + background: #f3f5f4; + border: 1px solid rgba(113, 141, 140, 0.2); + border-radius: 8px; + font-size: 12px; + line-height: 1; +} + +.console-v2 .joint-twin-shell .domain-readouts dt, +.console-v2 .joint-twin-shell .domain-readouts dd { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-v2 .joint-twin-shell .domain-readouts dt { + flex: 0 0 auto; + color: #243d40; + font-weight: 750; + text-align: left; +} + +.console-v2 .joint-twin-shell .domain-readouts dd { + flex: 0 1 auto; + margin: 0 0 0 auto; + color: var(--v2-ink); + font-family: "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + font-size: 13px; + font-weight: 900; + line-height: 1; + text-align: right; +} + +.console-v2 .joint-twin-shell .domain-readouts .ambient-color-value { + font-size: 12px; +} + +.console-v2 .joint-twin-shell .domain-readouts .color-swatch { + display: none; +} + +.console-v2 .joint-twin-shell .domain-readouts.single-readout > div { + grid-column: 1 / -1; +} + +.console-v2 .joint-twin-shell .window-panel, +.console-v2 .joint-twin-shell .lighting-panel { + min-height: 108px; +} + +.console-v2 .joint-seat-panel > .simulation-label { + display: none; +} + +.console-v2 .joint-seat-panel .seat-identity { + gap: 6px; +} + +.console-v2 .joint-seat-panel .seat-identity > svg { + box-sizing: border-box; + width: 24px; + height: 24px; + padding: 5px; + color: var(--v2-accent-strong); + background: var(--v2-accent-soft); + border: 1px solid color-mix(in srgb, var(--v2-accent) 16%, transparent); + border-radius: 6px; + stroke-width: 1.9; +} + +.console-v2 .joint-seat-panel .seat-identity strong { + font-size: 12px; +} + +.console-v2 .joint-seat-panel .seat-identity span { + font-size: 7.5px; +} + +.console-v2 .joint-seat-panel .dynamic-mode-state { + gap: 4px; + margin: 4px 0; + padding: 3px 5px; + color: #607878; + background: #edf3f1; + border-color: rgba(113, 141, 140, 0.16); + border-radius: 5px; + font-size: 12px; + font-weight: 750; + line-height: 1; +} + +.console-v2 .joint-seat-panel .dynamic-mode-state svg { + width: 12px; + height: 12px; +} + +.console-v2 .joint-seat-panel .dynamic-mode-state b { + color: var(--v2-ink); + font-family: "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + font-size: 12px; + font-weight: 900; + line-height: 1; +} + +.console-v2 .joint-seat-panel .thermal-states { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin-top: 4px; +} + +.console-v2 .joint-seat-panel .thermal-states > span { + grid-template-columns: 10px minmax(0, 1fr) auto; + padding: 2px 5px; + color: #243d40; + font-size: 12px; + font-weight: 750; + line-height: 1; +} + +.console-v2 .joint-seat-panel .thermal-states svg { + width: 12px; + height: 12px; +} + +.console-v2 .joint-seat-panel .thermal-states b { + color: var(--v2-ink); + font-family: "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + font-size: 13px; + font-weight: 900; + line-height: 1; +} + +.console-v2 .joint-seat-panel .motion-state-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 5px; + margin-top: 5px; + padding-top: 5px; +} + +.console-v2 .joint-seat-panel .motion-axis { + min-height: 29px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + padding: 5px 7px; + color: #243d40; + background: #f3f5f4; + border: 1px solid rgba(113, 141, 140, 0.2); + border-radius: 8px; + font-size: 12px; + font-weight: 750; + line-height: 1; +} + +.console-v2 .joint-seat-panel .motion-axis b { + color: var(--v2-ink); + font-family: "SFMono-Regular", Consolas, "Microsoft YaHei", monospace; + font-size: 13px; + font-weight: 900; + line-height: 1; +} + +.console-v2 .joint-seat-panel .motion-axis::after, +.console-v2 .joint-seat-panel .motion-axis i { + display: none; +} + +.v2-page-rail { + min-width: 0; + grid-column: 2; + grid-row: 1 / -1; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 3px; + padding: 13px 10px 13px 5px; + border-left: 1px solid rgba(114, 140, 140, 0.1); +} + +.v2-page-rail[hidden] { + display: none !important; +} + +.v2-page-button { + min-height: 49px; + display: grid; + grid-template-columns: 18px 12px minmax(0, 1fr); + align-items: center; + gap: 6px; + padding: 7px 6px; + color: #8d9b9c; + background: transparent; + border: 0; + border-radius: 9px; + text-align: left; + cursor: pointer; + transition: 150ms ease; +} + +.v2-page-button:hover { + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 42%, white); +} + +.v2-page-button > span { + font: 750 8px "SFMono-Regular", Consolas, monospace; +} + +.v2-page-button > i { + width: 7px; + height: 7px; + justify-self: center; + background: #d7dddd; + border-radius: 50%; + transition: 150ms ease; +} + +.v2-page-button > strong { + overflow: hidden; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.v2-page-button.active { + color: var(--v2-accent-strong); + background: color-mix(in srgb, var(--v2-accent-soft) 66%, white); +} + +.v2-page-button.active > i { + width: 12px; + height: 12px; + background: var(--v2-accent); + box-shadow: 0 0 0 5px color-mix(in srgb, var(--v2-accent) 10%, transparent); +} + +.console-v2 .toast { + right: 24px; + bottom: 22px; + border-radius: 10px; +} + +/* Compact desktop heights ------------------------------------------------ */ + +@media (max-height: 820px) { + .console-v2 .workspace { + grid-template-columns: clamp(235px, 18vw, 280px) minmax(0, 1fr); + } + + .console-v2.v2-sidebar-collapsed .workspace { + grid-template-columns: 66px minmax(0, 1fr); + } + + .console-v2 .v2-sidebar { + padding-top: 14px; + padding-bottom: 10px; + } + + .v2-profile { + grid-template-columns: 50px minmax(0, 1fr) 28px; + gap: 10px; + min-height: 52px; + } + + .v2-role-select > span, + .v2-role-select select, + .v2-role-select .v2-dropdown-trigger { + font-size: 20px; + } + + .v2-avatar { + width: 48px; + height: 48px; + } + + .v2-profile-copy > small, + .v2-section-heading span { + display: none; + } + + .v2-control-section { + margin-top: 14px; + } + + .v2-section-heading h2 { + margin: 0 0 10px; + font-size: 22px; + } + + .v2-control-section > .v2-field-caption, + .v2-config-section > .v2-field-caption { + margin-bottom: 5px; + font-size: 11px; + } + + .console-v2 .v2-config-field > .field-label, + .console-v2 .v2-config-field > label.field-label { + margin-bottom: 4px; + font-size: 9px; + } + + .v2-control-switch { + grid-template-columns: 1fr; + } + + .v2-control-switch button { + min-height: 41px; + grid-template-columns: 21px minmax(0, 1fr); + padding: 0 9px; + font-size: 10px; + } + + .v2-config-section { + margin-top: 12px; + } + + .v2-divider { + margin-bottom: 9px; + } + + .v2-config-stack { + gap: 8px; + padding-left: 8px; + } + + .console-v2 .v2-vehicle-field .vehicle-select-control, + .v2-static-select, + .v2-select-shell { + min-height: 39px; + } + + .console-v2 .v2-vehicle-field select, + .v2-select-shell select { + height: 37px; + font-size: 12px; + } + + .console-v2 .v2-config-field .provider-note { + display: none; + } + + .console-v2 .main-panel { + grid-template-rows: clamp(207px, 29dvh, 236px) minmax(0, 1fr); + gap: 12px; + padding: 9px; + } + + .v2-natural-panel { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(102px, 1fr) auto; + gap: 5px; + padding: 12px 20px 8px; + } + + .console-v2 .v2-gesture-panel { + gap: 5px; + padding: 12px 20px 8px; + } + + .console-v2 .v2-operation-heading h2 { + font-size: 24px; + } + + .console-v2 .v2-operation-heading .panel-intro { + display: none; + } + + .console-v2 .v2-request-form .command-composer, + .console-v2 .v2-request-form #utterance { + height: 102px; + min-height: 102px; + } + + .console-v2 .v2-request-form #utterance { + height: 100px; + min-height: 100px; + padding: 13px 64px 25px 17px; + } + + .console-v2 .v2-request-form .primary-action { + width: 32px; + height: 32px; + min-height: 32px; + right: 9px; + bottom: 9px; + } + + .console-v2 .v2-request-form .composer-hint { + right: 48px; + bottom: 8px; + } + + .console-v2 .v2-request-status { + height: 102px; + min-height: 102px; + padding: 12px; + } + + .console-v2 .v2-mode-rail .mode-chips button { + min-width: 86px; + min-height: 29px; + padding: 4px 10px; + font-size: 9px; + } + + .console-v2 .v2-inline-gesture .gesture-footnote { + display: none; + } + + .v2-output-shell { + grid-template-rows: 49px minmax(0, 1fr); + } + + .v2-output-heading { + padding-top: 6px; + padding-bottom: 5px; + } + + .v2-output-content { + margin: 0 9px 9px; + } + + .v2-output-shell.v2-twin-active .v2-output-content { + margin: 4px 8px; + } + + .console-v2 .joint-twin-shell.v2-output-page { + gap: 8px; + padding: 3px 6px; + } + + .v2-sidebar-footer { + min-height: 42px; + padding-top: 7px; + } + + .console-v2 .v2-advanced-field:not([hidden]) { + max-height: 92px; + overflow: auto; + } +} + +@media (max-width: 1366px) { + .console-v2 { + min-width: 1120px; + } + + .console-v2 .workspace { + grid-template-columns: 245px minmax(0, 1fr); + } + + .console-v2.v2-sidebar-collapsed .workspace { + grid-template-columns: 66px minmax(0, 1fr); + } + + .v2-natural-panel { + grid-template-columns: minmax(0, 1fr); + } + + .v2-natural-grid { + grid-template-columns: minmax(0, 1fr) 165px; + } + + .v2-page-rail { + padding-right: 6px; + } + + .v2-page-button { + grid-template-columns: 12px 10px minmax(0, 1fr); + gap: 4px; + } +} + +@media (min-width: 2000px) { + .console-v2 .workspace { + grid-template-columns: 330px minmax(0, 1fr); + } + + .console-v2.v2-sidebar-collapsed .workspace { + grid-template-columns: 76px minmax(0, 1fr); + } + + .console-v2 .v2-sidebar { + padding-right: 34px; + padding-left: 34px; + } + + .console-v2 .main-panel { + grid-template-rows: 350px minmax(0, 1fr); + gap: 18px; + padding: 24px; + } + + .v2-operation-shell, + .v2-output-shell { + border-radius: 26px; + } +} + +@media (prefers-reduced-motion: reduce) { + .console-v2 *, + .console-v2 *::before, + .console-v2 *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js new file mode 100644 index 0000000..a3d9eec --- /dev/null +++ b/apps/web-console/console-v2.js @@ -0,0 +1,667 @@ +(function () { + "use strict"; + + const pageDefinitions = [ + { index: 0, label: "整车联合数字孪生", shortLabel: "数字孪生", target: ".joint-twin-shell" }, + { index: 1, label: "决策轨迹与校验", shortLabel: "决策校验", target: "#panel-trace" }, + { index: 2, label: "指令配置与硬件映射", shortLabel: "配置映射", target: "#panel-config" }, + { index: 3, label: "CAN 编译计划", shortLabel: "CAN 计划", target: "#panel-plan" }, + { index: 4, label: "执行结果与硬件回读", shortLabel: "执行回读", target: "#panel-result" }, + ]; + + const $ = (selector, root = document) => root.querySelector(selector); + const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + + const ui = { + role: sessionStorage.getItem("cognitive-agent.demo-role") === "admin" ? "admin" : "user", + controlMode: sessionStorage.getItem("cognitive-agent.control-mode") === "gesture" + ? "gesture" + : "natural", + activePage: 0, + sidebarCollapsed: sessionStorage.getItem("cognitive-agent.sidebar-collapsed") === "true", + }; + + const dropdownControls = new Map(); + + function make(tag, className, html) { + const node = document.createElement(tag); + if (className) node.className = className; + if (html != null) node.innerHTML = html; + return node; + } + + function closeCustomDropdowns(except = null) { + dropdownControls.forEach((control) => { + if (control !== except) control.close(); + }); + } + + function enhanceSelect(select) { + if (!select || dropdownControls.has(select)) return; + const shell = select.parentElement; + if (!shell) return; + + shell.classList.add("v2-custom-select"); + select.classList.add("v2-native-select"); + + const trigger = make("button", "v2-dropdown-trigger", ` + + `); + trigger.type = "button"; + trigger.setAttribute("aria-haspopup", "listbox"); + trigger.setAttribute("aria-expanded", "false"); + trigger.setAttribute("aria-label", select.getAttribute("aria-label") || "打开下拉菜单"); + + const menu = make("div", "v2-dropdown-menu", ""); + menu.id = `${select.id}Menu`; + menu.hidden = true; + menu.setAttribute("role", "listbox"); + menu.setAttribute("aria-label", select.getAttribute("aria-label") || "选项"); + trigger.setAttribute("aria-controls", menu.id); + shell.append(trigger, menu); + + const valueNode = $(".v2-dropdown-value", trigger); + const render = () => { + const selected = select.selectedOptions[0] || select.options[0]; + valueNode.textContent = selected?.textContent?.trim() || "请选择"; + trigger.disabled = select.disabled; + menu.innerHTML = ""; + [...select.options].forEach((option) => { + const item = make("button", "v2-dropdown-option", ""); + item.type = "button"; + item.dataset.value = option.value; + item.textContent = option.textContent.trim(); + item.disabled = option.disabled; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(option.value === select.value)); + menu.append(item); + }); + }; + + const close = () => { + menu.hidden = true; + shell.classList.remove("v2-dropdown-open"); + trigger.setAttribute("aria-expanded", "false"); + }; + + const open = () => { + if (trigger.disabled) return; + closeCustomDropdowns(); + render(); + menu.hidden = false; + shell.classList.add("v2-dropdown-open"); + trigger.setAttribute("aria-expanded", "true"); + }; + + const control = { close, menu, open, select, shell, sync: render, trigger }; + dropdownControls.set(select, control); + + trigger.addEventListener("click", (event) => { + event.stopPropagation(); + if (menu.hidden) open(); + else close(); + }); + trigger.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + open(); + requestAnimationFrame(() => { + $(".v2-dropdown-option[aria-selected=\"true\"]:not(:disabled)", menu)?.focus(); + }); + } else if (event.key === "Escape") { + close(); + } + }); + menu.addEventListener("click", (event) => { + event.stopPropagation(); + const item = event.target.closest(".v2-dropdown-option"); + if (!item || item.disabled) return; + select.value = item.dataset.value; + select.dispatchEvent(new Event("change", { bubbles: true })); + render(); + close(); + trigger.focus(); + }); + menu.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + close(); + trigger.focus(); + }); + select.addEventListener("change", render); + new MutationObserver(render).observe(select, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + render(); + } + + function enhanceConfiguredDropdowns() { + [$("#v2RoleSelect"), $("#vehicleSelect"), $("#v2ExecutionSelect")] + .filter(Boolean) + .forEach(enhanceSelect); + } + + function buildSidebar(commandPanel) { + commandPanel.classList.add("v2-sidebar"); + commandPanel.innerHTML = ""; + + const profile = make("section", "v2-profile", ` + +
+ WELCOME TO THE CABIN +
+ Hi, + +
+
+ `); + + const control = make("section", "v2-sidebar-section v2-control-section", ` +
+ CONTROL DESK +

控制台

+
+ 控制方式 +
+ + +
`); + + const config = make("section", "v2-sidebar-section v2-config-section", ` +
+ 配置中心 +
`); + + const footer = make("footer", "v2-sidebar-footer", ` + +
智舱控制链路正在连接服务
+ `); + + commandPanel.append(profile, control, config, footer); + $("#v2RoleSelect").value = ui.role; + return $("#v2ConfigStack"); + } + + function buildConfigFields(configStack, originalForm) { + const vehicleField = $(".vehicle-field", originalForm); + const providerField = $(".field-group:has(.segmented [data-provider])", originalForm); + const executionField = $(".field-group:has(.execution-mode-options)", originalForm); + const hardwareAccessField = $("#hardwareAccessField", originalForm); + const simulationFaultField = $("#simulationFaultField", originalForm); + const approvalField = $("#approvalField", originalForm); + + if (vehicleField) { + vehicleField.classList.add("v2-config-field", "v2-vehicle-field"); + configStack.append(vehicleField); + } + + const modelField = make("div", "v2-config-field v2-model-field", ` + 推理模型 +
+ + 模型检测中 + AUTO +
+

模型由服务端安全配置

`); + configStack.append(modelField); + + const gestureModelField = make("div", "v2-config-field v2-gesture-model-field", ` + 手势模型 +
+ + MediaPipe GR + VISION +
+ Tasks Vision · 0.10.14`); + configStack.append(gestureModelField); + + const userHardwareState = make("div", "v2-user-target-status", ` + 正在检查真实台架`); + configStack.append(userHardwareState); + + if (providerField) { + providerField.classList.add("v2-provider-field"); + providerField.hidden = true; + configStack.append(providerField); + } + + const executionSelect = make("div", "v2-config-field v2-execution-field", ` + +
+ + +
+

管理员可切换执行环境

`); + configStack.append(executionSelect); + + if (executionField) { + executionField.classList.add("v2-native-execution-field"); + executionField.hidden = true; + configStack.append(executionField); + } + + const advanced = make("div", "v2-advanced-stack", ""); + [hardwareAccessField, simulationFaultField, approvalField].forEach((field) => { + if (!field) return; + field.classList.add("v2-config-field", "v2-advanced-field"); + advanced.append(field); + }); + configStack.append(advanced); + } + + function buildOperation(mainPanel, originalForm, panelHeading, modeRail, requestHeader, gestureDrawer) { + const operation = make("section", "v2-operation-shell", ` + +
+
`); + mainPanel.prepend(operation); + + const naturalPanel = $("#v2NaturalPanel"); + if (panelHeading) { + panelHeading.classList.add("v2-operation-heading"); + const kicker = $(".section-kicker", panelHeading); + const title = $("h2", panelHeading); + const intro = $(".panel-intro", panelHeading); + if (kicker) kicker.textContent = "自然语言控制"; + if (title) title.textContent = "一句话,调好整车"; + if (intro) intro.textContent = "座椅、空调、天幕与氛围灯,一句话联动完成"; + naturalPanel.append(panelHeading); + } + + const naturalGrid = make("div", "v2-natural-grid", ""); + originalForm.classList.add("v2-request-form"); + naturalGrid.append(originalForm); + if (requestHeader) { + requestHeader.classList.add("v2-request-status"); + naturalGrid.append(requestHeader); + } + naturalPanel.append(naturalGrid); + if (modeRail) { + modeRail.classList.add("v2-mode-rail"); + naturalPanel.append(modeRail); + } + + const gesturePanel = $("#v2GesturePanel"); + const gestureHeading = make("header", "v2-operation-heading v2-gesture-operation-heading", ` + 手势控制 +

抬抬手,控制整车

+

通过摄像头识别手势,直观控制车舱功能

`); + gesturePanel.append(gestureHeading); + if (gestureDrawer) { + gestureDrawer.classList.add("v2-inline-gesture"); + const cameraStage = $(".gesture-camera-stage", gestureDrawer); + const cameraToggle = $("#gestureStartButton", gestureDrawer); + if (cameraStage && cameraToggle) { + cameraToggle.classList.add("v2-camera-toggle"); + cameraToggle.setAttribute("aria-label", "开启摄像头"); + cameraToggle.setAttribute("title", "开启摄像头"); + cameraStage.append(cameraToggle); + } + gesturePanel.append(gestureDrawer); + } + return operation; + } + + function buildOutput(mainPanel) { + const output = make("section", "v2-output-shell", ` +
+
+ 00 +
INFORMATION OUTPUT

整车联合数字孪生

+
+ LIVE STATE +
+
+ `); + mainPanel.append(output); + + const content = $("#v2OutputContent"); + const twin = $(".joint-twin-shell", mainPanel); + const resultWorkspace = $(".result-workspace", mainPanel); + if (twin) content.append(twin); + if (resultWorkspace) content.append(resultWorkspace); + + pageDefinitions.forEach((page) => { + const target = $(page.target, content); + if (!target) return; + target.dataset.v2Page = String(page.index); + target.classList.add("v2-output-page"); + }); + + pageDefinitions.forEach((page) => { + const button = make("button", "v2-page-button", ` + ${String(page.index).padStart(2, "0")} + + ${page.shortLabel}`); + button.type = "button"; + button.dataset.v2PageTarget = String(page.index); + button.setAttribute("aria-label", `${String(page.index).padStart(2, "0")} ${page.label}`); + $("#v2PageRail").append(button); + }); + } + + function setPage(pageIndex) { + const maxPage = ui.role === "admin" ? pageDefinitions.length - 1 : 0; + ui.activePage = Math.max(0, Math.min(Number(pageIndex) || 0, maxPage)); + const definition = pageDefinitions[ui.activePage]; + $(".v2-output-shell")?.classList.toggle("v2-twin-active", definition.index === 0); + $$(".v2-output-page").forEach((page) => { + const active = Number(page.dataset.v2Page) === ui.activePage; + page.classList.toggle("v2-active-output", active); + page.setAttribute("aria-hidden", active ? "false" : "true"); + }); + $$(".v2-page-button").forEach((button) => { + const active = Number(button.dataset.v2PageTarget) === ui.activePage; + button.classList.toggle("active", active); + button.setAttribute("aria-current", active ? "page" : "false"); + }); + $("#v2PageIndex").textContent = String(definition.index).padStart(2, "0"); + $("#v2PageTitle").textContent = definition.label; + $("#v2OutputState").textContent = definition.index === 0 ? "LIVE STATE" : "AUDIT VIEW"; + requestAnimationFrame(updateScrollCues); + } + + function updateScrollCues() { + $$(".result-workspace .tab-panel").forEach((panel) => { + const active = panel.classList.contains("v2-active-output"); + panel.classList.toggle( + "v2-scrollable", + active && panel.scrollHeight > panel.clientHeight + 6, + ); + }); + } + + function updateModeEdgeHints() { + const chips = $(".v2-mode-rail .mode-chips"); + if (!chips) return; + const buttons = $$('button', chips); + buttons.forEach((button) => { + button.classList.remove("v2-edge-fade-left", "v2-edge-fade-right"); + button.style.removeProperty("--v2-edge-fade-start"); + button.style.removeProperty("--v2-edge-fade-end"); + }); + if (buttons.length < 2 || chips.clientWidth <= 0) return; + + const viewport = chips.getBoundingClientRect(); + const visible = buttons.map((button) => ({ + button, + rect: button.getBoundingClientRect(), + })).filter(({ rect }) => { + return rect.right > viewport.left + 1 && rect.left < viewport.right - 1; + }); + if (!visible.length) return; + + const fadeSpan = 30; + const firstVisible = visible[0]; + const lastVisible = visible[visible.length - 1]; + if (firstVisible.rect.left < viewport.left - 1) { + const clippedAt = Math.min(100, Math.max( + 0, + ((viewport.left - firstVisible.rect.left) / firstVisible.rect.width) * 100, + )); + firstVisible.button.style.setProperty("--v2-edge-fade-start", `${clippedAt}%`); + firstVisible.button.style.setProperty( + "--v2-edge-fade-end", + `${Math.min(100, clippedAt + fadeSpan)}%`, + ); + firstVisible.button.classList.add("v2-edge-fade-left"); + } + if (lastVisible.rect.right > viewport.right + 1) { + const clippedAt = Math.min(100, Math.max( + 0, + ((viewport.right - lastVisible.rect.left) / lastVisible.rect.width) * 100, + )); + lastVisible.button.style.setProperty( + "--v2-edge-fade-start", + `${Math.max(0, clippedAt - fadeSpan)}%`, + ); + lastVisible.button.style.setProperty("--v2-edge-fade-end", `${clippedAt}%`); + lastVisible.button.classList.add("v2-edge-fade-right"); + } + } + + function setControlMode(mode, { triggerGesture = true } = {}) { + ui.controlMode = mode === "gesture" ? "gesture" : "natural"; + sessionStorage.setItem("cognitive-agent.control-mode", ui.controlMode); + document.body.dataset.controlMode = ui.controlMode; + $$('[data-v2-control]').forEach((button) => { + const active = button.dataset.v2Control === ui.controlMode; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + }); + requestAnimationFrame(updateModeEdgeHints); + const drawer = $("#gestureDrawer"); + if (!triggerGesture) { + const gestureVisible = ui.controlMode === "gesture"; + drawer?.setAttribute("aria-hidden", String(!gestureVisible)); + $("#gestureToggleButton")?.setAttribute("aria-expanded", String(gestureVisible)); + return; + } + if (ui.controlMode === "gesture") { + if (!drawer?.classList.contains("open")) $("#gestureToggleButton")?.click(); + } else if (drawer?.classList.contains("open")) { + $("#gestureCloseButton")?.click(); + } + } + + function setRole(role) { + ui.role = role === "admin" ? "admin" : "user"; + sessionStorage.setItem("cognitive-agent.demo-role", ui.role); + document.body.dataset.demoRole = ui.role; + window.dispatchEvent(new CustomEvent("cognitive-agent:demo-role-change", { + detail: { role: ui.role }, + })); + $("#v2RoleSelect").value = ui.role; + dropdownControls.get($("#v2RoleSelect"))?.sync(); + $("#v2PageRail").hidden = ui.role !== "admin"; + if (ui.role !== "admin") { + setPage(0); + requestUserHardwareMode(); + } else { + const note = $("#v2ExecutionNote"); + if (note) note.textContent = "管理员可切换虚拟 CAN、模拟台架与真实台架"; + const runButton = $("#runButton"); + if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false; + syncExecutionSelector(); + } + } + + function setSidebarCollapsed(collapsed) { + ui.sidebarCollapsed = Boolean(collapsed); + sessionStorage.setItem( + "cognitive-agent.sidebar-collapsed", + String(ui.sidebarCollapsed), + ); + document.body.classList.toggle("v2-sidebar-collapsed", ui.sidebarCollapsed); + const button = $("#v2SidebarToggle"); + if (button) { + button.setAttribute("aria-expanded", String(!ui.sidebarCollapsed)); + button.setAttribute( + "aria-label", + ui.sidebarCollapsed ? "展开左侧控制台" : "折叠左侧控制台", + ); + button.innerHTML = ``; + } + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + window.setTimeout(() => window.dispatchEvent(new Event("resize")), 180); + } + + function requestUserHardwareMode() { + const hardwareButton = $("#hardwareMode"); + const note = $("#v2ExecutionNote"); + const userTarget = $(".v2-user-target-status"); + const userTargetText = $("#v2UserTargetText"); + const runButton = $("#runButton"); + if (!hardwareButton) return; + if (!hardwareButton.disabled) { + if (!hardwareButton.classList.contains("active")) hardwareButton.click(); + if (note) note.textContent = "user 演示固定使用真实台架"; + if (userTarget) userTarget.dataset.state = "ready"; + if (userTargetText) userTargetText.textContent = "执行目标:真实台架"; + if (runButton && !runButton.classList.contains("loading")) runButton.disabled = false; + } else if (note) { + note.textContent = "真实台架尚未就绪,服务端安全门禁保持锁定"; + if (userTarget) userTarget.dataset.state = "locked"; + if (userTargetText) userTargetText.textContent = "真实台架尚未就绪"; + if (runButton) runButton.disabled = true; + } + } + + function syncExecutionSelector() { + const select = $("#v2ExecutionSelect"); + if (!select) return; + const active = $('[data-execution-mode].active'); + if (active) select.value = active.dataset.executionMode; + ["dry_run", "simulated_hardware", "hardware"].forEach((value) => { + const option = $(`option[value="${value}"]`, select); + const button = $(`[data-execution-mode="${value}"]`); + if (option && button) option.disabled = button.disabled; + }); + dropdownControls.get(select)?.sync(); + } + + function syncRuntimePresentation() { + const modelBadge = $("#modelBadge"); + const connectionBadge = $("#connectionBadge"); + const modelValue = $("#v2ModelValue"); + const modelNote = $("#v2ModelNote"); + const connectionText = $("#v2ConnectionText"); + if (modelBadge && modelValue) { + const text = modelBadge.textContent.trim() || "模型检测中"; + modelValue.textContent = text; + const unavailable = modelBadge.classList.contains("danger"); + $("#v2ModelField")?.classList.toggle("unavailable", unavailable); + $("#v2ModelState").textContent = unavailable ? "LOCAL" : "READY"; + if (modelNote) modelNote.textContent = unavailable + ? "GPT 不可用,自动使用本地规则" + : "服务端安全托管 · 失败自动降级"; + } + if (connectionBadge && connectionText) { + connectionText.textContent = connectionBadge.textContent.trim() || "正在连接服务"; + const online = !connectionBadge.classList.contains("danger") + && !connectionBadge.classList.contains("warning"); + $(".v2-sidebar-footer")?.classList.toggle("offline", !online); + } + syncExecutionSelector(); + if (ui.role === "user") requestUserHardwareMode(); + } + + function bindEvents() { + $("#v2RoleSelect")?.addEventListener("change", (event) => setRole(event.target.value)); + $$('[data-v2-control]').forEach((button) => { + button.addEventListener("click", () => setControlMode(button.dataset.v2Control)); + }); + $("#v2ExecutionSelect")?.addEventListener("change", (event) => { + $(`[data-execution-mode="${event.target.value}"]`)?.click(); + syncExecutionSelector(); + }); + $$(".v2-page-button").forEach((button) => { + button.addEventListener("click", () => setPage(button.dataset.v2PageTarget)); + }); + $("#v2LogoutButton")?.addEventListener("click", () => $("#logoutButton")?.click()); + $("#v2SidebarToggle")?.addEventListener( + "click", + () => setSidebarCollapsed(!ui.sidebarCollapsed), + ); + document.addEventListener("click", () => closeCustomDropdowns()); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeCustomDropdowns(); + }); + window.addEventListener("resize", updateScrollCues); + window.addEventListener("resize", updateModeEdgeHints); + window.addEventListener("cognitive-agent:result", () => requestAnimationFrame(updateScrollCues)); + const modeChips = $(".v2-mode-rail .mode-chips"); + modeChips?.addEventListener( + "scroll", + () => requestAnimationFrame(updateModeEdgeHints), + { passive: true }, + ); + if (modeChips) { + new MutationObserver(() => requestAnimationFrame(updateModeEdgeHints)) + .observe(modeChips, { childList: true }); + } + + const observer = new MutationObserver(syncRuntimePresentation); + [$("#modelBadge"), $("#connectionBadge"), $("#hardwareMode"), $("#simulationMode")] + .filter(Boolean) + .forEach((node) => observer.observe(node, { + attributes: true, + childList: true, + subtree: true, + characterData: true, + })); + } + + function init() { + const workspace = $(".workspace"); + const commandPanel = $(".command-panel"); + const mainPanel = $(".main-panel"); + const originalForm = $("#requestForm"); + if (!workspace || !commandPanel || !mainPanel || !originalForm) return; + + document.body.classList.add("console-v2"); + const panelHeading = $(".panel-heading", commandPanel); + const modeRail = $(".mode-rail", commandPanel); + const exampleGroup = $(".example-group", commandPanel); + const safetyStrip = $("#safetyStrip", commandPanel); + const requestHeader = $(".request-header", mainPanel); + const gestureDrawer = $("#gestureDrawer"); + + const detached = { + panelHeading, + modeRail, + requestHeader, + gestureDrawer, + originalForm, + exampleGroup, + safetyStrip, + }; + Object.values(detached).filter(Boolean).forEach((node) => node.remove()); + + const legacyParking = make("div", "v2-legacy-parking v2-legacy-hidden", ""); + [exampleGroup, safetyStrip].filter(Boolean).forEach((node) => legacyParking.append(node)); + document.body.append(legacyParking); + + const configStack = buildSidebar(commandPanel); + buildConfigFields(configStack, originalForm); + enhanceConfiguredDropdowns(); + buildOperation( + mainPanel, + originalForm, + panelHeading, + modeRail, + requestHeader, + gestureDrawer, + ); + + $(".pipeline", mainPanel)?.classList.add("v2-legacy-hidden"); + $(".topbar")?.classList.add("v2-legacy-hidden"); + $(".gesture-backdrop")?.classList.add("v2-legacy-hidden"); + buildOutput(mainPanel); + bindEvents(); + setControlMode(ui.controlMode, { triggerGesture: false }); + setRole(ui.role); + setSidebarCollapsed(ui.sidebarCollapsed); + setPage(0); + syncRuntimePresentation(); + window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); + requestAnimationFrame(() => { + updateModeEdgeHints(); + document.body.classList.add("v2-ready"); + }); + } + + document.addEventListener("DOMContentLoaded", init); +}()); diff --git a/apps/web-console/gesture-control.js b/apps/web-console/gesture-control.js index 25a8f28..08422d1 100755 --- a/apps/web-console/gesture-control.js +++ b/apps/web-console/gesture-control.js @@ -55,6 +55,10 @@ const state = { customMapping: loadCustomMapping(), customizeOpen: false, engine: null, + runtimeReady: false, + cameraStatus: "checking", + cameraMessage: "正在检测摄像头…", + cameraCheckSequence: 0, }; const $ = (selector) => document.querySelector(selector); @@ -80,6 +84,91 @@ function notify(message) { window.setTimeout(() => toast.classList.remove("visible"), 2600); } +function cameraFailureDetails(error) { + if (!navigator.mediaDevices?.getUserMedia) { + return window.isSecureContext === false + ? { status: "unsupported", message: "当前页面不是安全连接,浏览器已阻止摄像头访问" } + : { status: "unsupported", message: "当前浏览器不支持摄像头访问" }; + } + switch (error?.name) { + case "NotFoundError": + case "DevicesNotFoundError": + return { status: "unavailable", message: "未检测到可用摄像头" }; + case "NotAllowedError": + case "PermissionDeniedError": + case "SecurityError": + return { status: "blocked", message: "摄像头权限被拒绝,请在浏览器设置中允许访问" }; + case "NotReadableError": + case "TrackStartError": + return { status: "busy", message: "摄像头正被其他程序占用或暂时不可用" }; + case "OverconstrainedError": + case "ConstraintNotSatisfiedError": + return { status: "incompatible", message: "摄像头不支持当前画面参数" }; + case "AbortError": + return { status: "unknown", message: "摄像头启动被中断,请重试" }; + default: + return { status: "unknown", message: error?.message || "摄像头启动失败" }; + } +} + +function syncCameraButtonAvailability() { + const button = $("#gestureStartButton"); + if (!button || state.stream) return; + button.disabled = !state.runtimeReady + || ["checking", "unavailable", "unsupported"].includes(state.cameraStatus); +} + +function setCameraAvailability(status, message, { announce = true } = {}) { + state.cameraStatus = status; + state.cameraMessage = message; + $("#gestureDrawer")?.setAttribute("data-camera-status", status); + syncCameraButtonAvailability(); + if (announce && !state.stream) $("#gestureSafetyText").textContent = message; +} + +async function refreshCameraAvailability({ announce = true } = {}) { + if (!navigator.mediaDevices?.enumerateDevices || !navigator.mediaDevices?.getUserMedia) { + const failure = cameraFailureDetails(); + setCameraAvailability(failure.status, failure.message, { announce }); + return false; + } + + const checkSequence = ++state.cameraCheckSequence; + if (!state.stream) setCameraAvailability("checking", "正在检测摄像头…", { announce }); + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available"; + const available = devices.some((device) => device.kind === "videoinput"); + setCameraAvailability( + available ? "available" : "unavailable", + available ? "已检测到摄像头,点击开启开始识别" : "未检测到可用摄像头", + { announce }, + ); + return available; + } catch (error) { + if (checkSequence !== state.cameraCheckSequence) return state.cameraStatus === "available"; + const failure = cameraFailureDetails(error); + const message = failure.status === "blocked" + ? "尚未获得摄像头权限,可点击开启并在浏览器中授权" + : "暂时无法检测摄像头,可点击开启时再次确认"; + setCameraAvailability("unknown", message, { announce }); + return null; + } +} + +async function handleCameraDeviceChange() { + const streamWasActive = Boolean(state.stream); + const available = await refreshCameraAvailability({ announce: !streamWasActive }); + const streamStillLive = state.stream?.getVideoTracks().some((track) => track.readyState === "live"); + if (streamWasActive && (available === false || !streamStillLive)) { + const message = available === false + ? "摄像头连接已断开,未检测到其他可用摄像头" + : "当前摄像头连接已断开,请重新开启摄像头"; + stopCamera({ message }); + notify(message); + } +} + function distance(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } @@ -386,7 +475,6 @@ function updateEngineUI(engine, message) { pill.className = `gesture-state-pill ${engine.armed ? (engine.currentAction ? "moving" : "armed") : "idle"}`; pill.querySelector("b").textContent = engine.armed ? (engine.currentAction ? "执行中" : "已激活") : "已锁止"; $("#gestureSafetyText").textContent = message; - $("#gestureArmButton").textContent = engine.armed ? "已激活" : "激活控制"; } function executeGestureAction(action) { @@ -469,6 +557,7 @@ async function loadGestureRuntime() { state.runtime = await response.json(); state.engine = new GestureEngine(state.runtime.safety, executeGestureAction); renderMappingGrid(); + renderGestureDebugOptions(); if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源"); } @@ -497,6 +586,8 @@ async function startCamera() { const button = $("#gestureStartButton"); button.disabled = true; button.querySelector("span").textContent = "正在启动…"; + button.setAttribute("aria-label", "正在启动摄像头"); + button.setAttribute("title", "正在启动摄像头"); try { if (!state.runtime) await loadGestureRuntime(); await loadRecognizer(); @@ -504,27 +595,30 @@ async function startCamera() { video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" }, audio: false, }); + setCameraAvailability("available", "摄像头可用,点击开启开始识别", { announce: false }); const video = $("#gestureVideo"); video.srcObject = state.stream; await video.play(); $("#gestureCameraEmpty").hidden = true; $("#gestureLiveBadge").hidden = false; - $("#gestureArmButton").disabled = false; $("#gestureStopButton").disabled = false; button.classList.add("active"); button.querySelector("span").textContent = "关闭摄像头"; + button.setAttribute("aria-label", "关闭摄像头"); + button.setAttribute("title", "关闭摄像头"); $("#gestureSafetyText").textContent = "张开手掌并保持 1 秒以激活"; state.frameRequest = requestAnimationFrame(recognizeLoop); } catch (error) { - $("#gestureSafetyText").textContent = error.message || "摄像头启动失败"; - notify(error.message || "摄像头启动失败"); - stopCamera(); + const failure = cameraFailureDetails(error); + setCameraAvailability(failure.status, failure.message, { announce: false }); + stopCamera({ message: failure.message }); + notify(failure.message); } finally { - button.disabled = false; + syncCameraButtonAvailability(); } } -function stopCamera() { +function stopCamera({ message = null } = {}) { if (state.frameRequest) cancelAnimationFrame(state.frameRequest); state.frameRequest = null; state.stream?.getTracks().forEach((track) => track.stop()); @@ -536,15 +630,17 @@ function stopCamera() { canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height); $("#gestureCameraEmpty").hidden = false; $("#gestureLiveBadge").hidden = true; - $("#gestureArmButton").disabled = true; $("#gestureStopButton").disabled = true; const button = $("#gestureStartButton"); button.classList.remove("active"); button.querySelector("span").textContent = "开启摄像头"; + button.setAttribute("aria-label", "开启摄像头"); + button.setAttribute("title", "开启摄像头"); state.engine?.setArmed(false); $("#gestureDetected").textContent = "—"; $("#gestureExpected").textContent = "等待启动"; - $("#gestureSafetyText").textContent = "摄像头尚未开启"; + $("#gestureSafetyText").textContent = message || state.cameraMessage || "摄像头尚未开启"; + syncCameraButtonAvailability(); } function toggleCamera() { @@ -562,6 +658,75 @@ function renderMappingGrid() { select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => ``).join(""); } +function renderGestureDebugOptions() { + const select = $("#gestureDebugSelect"); + if (!select || !state.runtime) return; + const tokens = [...new Set([ + "OPEN_PALM", + "CLOSED_FIST", + ...Object.keys(state.runtime.actions || {}), + ])]; + select.innerHTML = tokens.map((token) => { + const action = mappingForToken(token); + const actionLabel = action ? ACTIONS[action]?.label || action : token === "OPEN_PALM" ? "激活控制" : "停止当前动作"; + return ``; + }).join(""); +} + +function setGestureDebugOpen(open) { + const panel = $("#gestureDebugPanel"); + const button = $("#gestureArmButton"); + if (!panel || !button) return; + const allowed = document.body.dataset.demoRole === "admin"; + const nextOpen = allowed && open; + panel.hidden = !nextOpen; + button.classList.toggle("active", nextOpen); + button.setAttribute("aria-expanded", String(nextOpen)); +} + +function toggleGestureDebug() { + const panel = $("#gestureDebugPanel"); + setGestureDebugOpen(panel?.hidden ?? true); +} + +function sendSimulatedGesture() { + if (document.body.dataset.demoRole !== "admin") { + setGestureDebugOpen(false); + notify("仅管理员可发送模拟手势"); + return; + } + const token = $("#gestureDebugSelect")?.value; + if (!token || !state.engine) { + notify("手势调试能力尚未就绪"); + return; + } + const tokenName = TOKEN_NAMES[token] || token; + $("#gestureDetected").textContent = `模拟 · ${tokenName}`; + if (token === "OPEN_PALM") { + state.engine.setArmed(true); + $("#gestureExpected").textContent = "激活控制"; + notify("已发送模拟张掌手势"); + return; + } + if (token === "CLOSED_FIST") { + state.engine.stopMotion("管理员模拟握拳 · 已停止当前动作"); + $("#gestureExpected").textContent = "停止当前动作"; + notify("已发送模拟握拳手势"); + return; + } + const action = mappingForToken(token); + if (!action || !ACTIONS[action]) { + $("#gestureExpected").textContent = "未绑定动作"; + notify("该模拟手势尚未绑定动作"); + return; + } + state.lastRecognizedToken = token; + $("#gestureExpected").textContent = ACTIONS[action].label; + $("#gestureSafetyText").textContent = `管理员模拟手势:${tokenName}`; + executeGestureAction(action); + notify(`已发送模拟手势:${tokenName}`); +} + function updateCustomizeToken() { const token = state.lastRecognizedToken; $("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势"; @@ -635,26 +800,42 @@ function initModeRail() { } document.addEventListener("DOMContentLoaded", async () => { + $("#gestureStartButton").disabled = true; $("#gestureToggleButton")?.addEventListener("click", openDrawer); $("#gestureCloseButton")?.addEventListener("click", closeDrawer); $("#gestureBackdrop")?.addEventListener("click", closeDrawer); $("#gestureStartButton")?.addEventListener("click", toggleCamera); - $("#gestureArmButton")?.addEventListener("click", () => state.engine?.setArmed(!state.engine.armed)); + $("#gestureArmButton")?.addEventListener("click", toggleGestureDebug); $("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false)); + $("#gestureDebugSend")?.addEventListener("click", sendSimulatedGesture); $("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize); $("#gestureBindButton")?.addEventListener("click", bindCurrentGesture); $("#gestureResetMapping")?.addEventListener("click", resetMapping); document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && $("#gestureDrawer")?.classList.contains("open")) closeDrawer(); + if (event.key !== "Escape") return; + if (!$("#gestureDebugPanel")?.hidden) { + setGestureDebugOpen(false); + return; + } + if ($("#gestureDrawer")?.classList.contains("open")) closeDrawer(); + }); + window.addEventListener("cognitive-agent:demo-role-change", (event) => { + if (event.detail?.role !== "admin") setGestureDebugOpen(false); }); document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); }); window.addEventListener("beforeunload", stopCamera); + navigator.mediaDevices?.addEventListener?.("devicechange", handleCameraDeviceChange); initModeRail(); + let runtimeError = null; try { await loadGestureRuntime(); - $("#gestureSafetyText").textContent = state.runtime.available ? "能力就绪,开启摄像头开始识别" : "内置手势模型不可用"; + state.runtimeReady = Boolean(state.runtime.available && state.runtime.model_available); + if (!state.runtimeReady) runtimeError = new Error("内置手势模型不可用"); } catch (error) { - $("#gestureSafetyText").textContent = error.message; - $("#gestureStartButton").disabled = true; + runtimeError = error; + state.runtimeReady = false; } + await refreshCameraAvailability({ announce: !runtimeError }); + if (runtimeError) $("#gestureSafetyText").textContent = runtimeError.message; + syncCameraButtonAvailability(); }); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index d7d45fd..b8f7d27 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -8,16 +8,18 @@ - 智舱随行 · 一句话与手势控制 + 智舱随行 · 整车智能控制台 + - - + + +
@@ -147,7 +149,7 @@

正在检查自动烧录配置

- @@ -229,7 +231,6 @@
- JOINT DEVICE STATE

联合设备状态

MATRIX-BACKED · SIMULATED FEEDBACK @@ -252,6 +253,14 @@ 副驾24℃
+
+
+ 主驾风向- +
+
+ 副驾风向- +
+
风量
0 档
循环
外循环
@@ -273,20 +282,18 @@
-
透光率
100%
遮阳帘
0%
-
自动调光
关闭
-
电机反馈
已停止
+
调光
关闭
-
氛围灯AMBIENT · RGB 16.8M
+
阅读灯AMBIENT · RGB 16.8M
OFF
-
+
副驾 · 手套箱上方排灯
-
-
颜色
#74D9FF
-
亮度
0%
+
模式
关闭
-
控制精度
24-bit RGB
+
+
+ +
+
+ +
车窗WINDOW · OPENING
+ READY +
+
+
左前窗
0%
+
右前窗
0%
+
左后窗
0%
+
右后窗
0%
+
+
+ +
+
+ +
前大灯FRONT LIGHT · SWITCH
+ OFF +
+
+
近光灯
关闭
+
远光灯
关闭
+
雾灯
关闭
+
转向灯
关闭
@@ -312,26 +344,19 @@
- FULL VEHICLE DIGITAL TWIN

整车孪生界面

- 透明车体 · 实体设备
- -
- 全景天幕透光 100% - 仪表台 HVAC关闭 - 手套箱氛围灯关闭 -
+
副驾动态座椅ACTIVE · FRONT RIGHT