function createConversationId() { return `conv-${crypto.randomUUID()}`; } function loadConversationId() { const stored = sessionStorage.getItem("cognitive-agent.conversation-id"); if (stored?.startsWith("conv-")) return stored; const conversationId = createConversationId(); sessionStorage.setItem("cognitive-agent.conversation-id", conversationId); return conversationId; } const state = { provider: "openai", executionMode: "dry_run", conversationId: loadConversationId(), vehicleId: null, runtime: null, result: null, progressTimer: null, seatTwin: null, seatActuatorState: {}, seatFeedbackSource: "simulated", availableSeatAxes: null, connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting", }; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(input, options); // A four-axis hardware mode is executed serially. Each absolute move may wait // up to 33 seconds, and firmware recovery or known-range homing can extend the // request further. Keep the browser deadline above the backend's bounded HIL // workflow so the UI does not report failure while the seat is still moving. const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000; const REQUEST_TIMEOUT_MS = 45 * 1000; function requestTimeoutForExecutionMode(mode) { return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS; } function refreshIcons() { if (window.lucide) { window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); } } const TWIN_DEFAULT_POSITIONS = { longitudinal: 50, cushion_tilt: 50, backrest_recline: 20, legrest_height: 0, legrest_extension: 0, }; const TWIN_ANGLE_RANGES = { backrest_recline: [95, 150], legrest_height: [95, 167], }; function initJointTwinLayout() { const cabinBand = $(".cabin-band"); const twinBand = $(".twin-band"); const stateBand = $(".state-band"); const passengerCard = $('.seat-card[data-seat="passenger"]'); const domainGrid = $(".domain-grid"); const coverage = $(".coverage-section"); if (!cabinBand || !twinBand || !stateBand || !passengerCard || !domainGrid || !coverage) return; const shell = document.createElement("section"); shell.className = "joint-twin-shell"; shell.setAttribute("aria-labelledby", "jointTwinTitle"); const heading = document.createElement("header"); heading.className = "joint-twin-heading"; heading.innerHTML = `
VEHICLE DIGITAL TWIN · JOINT CONTROL

整车联合孪生总览

完整五座跨界车孪生:主驾与后排固定,副驾支持姿态、通风和加热动态反馈。

副驾座椅 空调 氛围灯 天幕
`; cabinBand.before(shell); shell.append(heading, twinBand, cabinBand); passengerCard.classList.add("joint-seat-panel", "domain-panel"); const seatFeedback = $("#seatFeedbackModeLabel"); if (seatFeedback) passengerCard.prepend(seatFeedback); domainGrid.prepend(passengerCard); coverage.classList.add("joint-hardware-boundary"); shell.append(coverage); stateBand.remove(); } function twinAngleForPosition(axis, positionPct) { const range = TWIN_ANGLE_RANGES[axis]; if (!range) return null; return range[0] + (range[1] - range[0]) * Number(positionPct) / 100; } function isEstimatedMotion(device, axis) { const motion = device?.position?.[axis]; return Boolean(motion?.position_estimated || motion?.position_valid === false); } function twinPostureAngles(device) { const position = (axis, fallback) => Number( device?.position?.[axis]?.position_pct ?? fallback, ); const included = (axis, fallback) => Number( device?.position?.[axis]?.angle_deg ?? twinAngleForPosition(axis, position(axis, fallback)), ); const cushion = position("cushion_tilt", 50) * 0.09; return { cushion_tilt: cushion, backrest_recline: included("backrest_recline", 20) - TWIN_ANGLE_RANGES.backrest_recline[0], legrest_height: included("legrest_height", 0) - TWIN_ANGLE_RANGES.legrest_height[0], }; } function makeDynamicLine(THREE, parent, pointCount, material, segments = false) { const positions = new Float32Array(pointCount * 3); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); const line = segments ? new THREE.LineSegments(geometry, material) : new THREE.Line(geometry, material); line.frustumCulled = false; line.renderOrder = 20; parent.add(line); return { line, positions, attribute: geometry.getAttribute("position") }; } function createTwinAngleGuide(THREE, parent, color) { const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.95, depthTest: false, }); const group = new THREE.Group(); parent.add(group); const dotMaterial = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.96, depthTest: false, }); const dotGeometry = new THREE.SphereGeometry(0.022, 8, 8); const dots = Array.from({ length: 13 }, () => { const dot = new THREE.Mesh(dotGeometry, dotMaterial); dot.renderOrder = 21; group.add(dot); return dot; }); dots[0].scale.setScalar(1.6); dots[dots.length - 1].scale.setScalar(1.6); return { group, arc: makeDynamicLine(THREE, group, 49, material), rays: makeDynamicLine(THREE, group, 4, material, true), dots, }; } function updateTwinAngleGuide(guide, x, pivotY, pivotZ, startDeg, sweepDeg, radius) { for (let index = 0; index < 49; index += 1) { const angle = (startDeg + sweepDeg * index / 48) * Math.PI / 180; const offset = index * 3; guide.arc.positions[offset] = x; guide.arc.positions[offset + 1] = pivotY + Math.sin(angle) * radius; guide.arc.positions[offset + 2] = pivotZ + Math.cos(angle) * radius; } const endpoints = [ [x, pivotY, pivotZ], [guide.arc.positions[0], guide.arc.positions[1], guide.arc.positions[2]], [x, pivotY, pivotZ], [ guide.arc.positions[guide.arc.positions.length - 3], guide.arc.positions[guide.arc.positions.length - 2], guide.arc.positions[guide.arc.positions.length - 1], ], ]; endpoints.flat().forEach((value, index) => { guide.rays.positions[index] = value; }); guide.dots.forEach((dot, index) => { const source = index * 4 * 3; dot.position.set( guide.arc.positions[source], guide.arc.positions[source + 1], guide.arc.positions[source + 2], ); }); guide.arc.attribute.needsUpdate = true; guide.rays.attribute.needsUpdate = true; } function makeTwinBox(THREE, parent, size, position, material, edgeMaterial) { const geometry = new THREE.BoxGeometry(...size); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(...position); mesh.castShadow = true; mesh.receiveShadow = true; const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 32), edgeMaterial); mesh.add(edges); parent.add(mesh); return mesh; } function makeCabinBeam(THREE, parent, start, end, radius, material) { const from = new THREE.Vector3(...start); const to = new THREE.Vector3(...end); const direction = to.clone().sub(from); const beam = new THREE.Mesh( new THREE.CylinderGeometry(radius, radius, direction.length(), 12), material, ); beam.position.copy(from).add(to).multiplyScalar(0.5); beam.quaternion.setFromUnitVectors( new THREE.Vector3(0, 1, 0), direction.normalize(), ); beam.castShadow = true; parent.add(beam); return beam; } function createTwinAirflowField(THREE, parent) { const count = 56; const positions = new Float32Array(count * 3); const seeds = Array.from({ length: count }, (_, index) => ( (index * 0.61803398875) % 1 )); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); const material = new THREE.PointsMaterial({ color: 0x55bdec, size: 0.055, sizeAttenuation: true, transparent: true, opacity: 0, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, }); const points = new THREE.Points(geometry, material); points.frustumCulled = false; points.renderOrder = 24; parent.add(points); return { points, positions, seeds, attribute: geometry.getAttribute("position"), material, level: 0, }; } function makeVehicleProfileShape(THREE) { const shape = new THREE.Shape(); shape.moveTo(-3.72, 0.2); shape.lineTo(-3.72, 0.92); shape.quadraticCurveTo(-3.6, 1.28, -3.12, 1.47); shape.quadraticCurveTo(-2.42, 1.51, -1.52, 1.45); shape.lineTo(1.86, 1.45); shape.quadraticCurveTo(2.06, 1.47, 2.22, 1.52); shape.quadraticCurveTo(2.88, 1.52, 3.48, 1.28); shape.quadraticCurveTo(3.76, 1.12, 3.74, 0.72); shape.lineTo(3.68, 0.24); shape.lineTo(3.12, 0.18); shape.bezierCurveTo(3.02, 0.76, 2.8, 1.3, 2.35, 1.3); shape.bezierCurveTo(1.9, 1.3, 1.68, 0.76, 1.58, 0.18); shape.lineTo(-1.48, 0.18); shape.bezierCurveTo(-1.58, 0.76, -1.8, 1.3, -2.25, 1.3); shape.bezierCurveTo(-2.7, 1.3, -2.92, 0.76, -3.02, 0.18); shape.lineTo(-3.72, 0.2); shape.closePath(); return shape; } function makeVehicleGreenhouseShape(THREE) { const shape = new THREE.Shape(); shape.moveTo(-2.62, 1.31); shape.quadraticCurveTo(-2.28, 1.8, -1.82, 2.42); shape.quadraticCurveTo(-0.48, 2.68, 0.92, 2.5); shape.quadraticCurveTo(1.58, 2.4, 2.18, 1.31); shape.closePath(); return shape; } function makeVehicleSideGlassShape(THREE) { const shape = new THREE.Shape(); shape.moveTo(-2.58, 1.32); shape.lineTo(-1.79, 2.36); shape.quadraticCurveTo(-0.5, 2.56, 1.03, 2.42); shape.lineTo(1.82, 1.32); shape.closePath(); return shape; } function makeRoundedPanelShape(THREE, width, length, radius) { const shape = new THREE.Shape(); const halfWidth = width / 2; const halfLength = length / 2; shape.moveTo(-halfWidth + radius, -halfLength); shape.lineTo(halfWidth - radius, -halfLength); shape.quadraticCurveTo(halfWidth, -halfLength, halfWidth, -halfLength + radius); shape.lineTo(halfWidth, halfLength - radius); shape.quadraticCurveTo(halfWidth, halfLength, halfWidth - radius, halfLength); shape.lineTo(-halfWidth + radius, halfLength); shape.quadraticCurveTo(-halfWidth, halfLength, -halfWidth, halfLength - radius); shape.lineTo(-halfWidth, -halfLength + radius); shape.quadraticCurveTo(-halfWidth, -halfLength, -halfWidth + radius, -halfLength); shape.closePath(); return shape; } function createTwinRearBench(THREE) { const root = new THREE.Group(); root.position.set(0, 0.12, -1.72); root.userData.twinRole = "fixed_rear_bench"; const leather = new THREE.MeshPhysicalMaterial({ color: 0x35484e, roughness: 0.58, metalness: 0.01, clearcoat: 0.12, clearcoatRoughness: 0.75, }); const insert = new THREE.MeshPhysicalMaterial({ color: 0x829497, roughness: 0.72, clearcoat: 0.05, }); const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.68, metalness: 0.25, }); const buckleMaterial = new THREE.MeshStandardMaterial({ color: 0xb64b43, roughness: 0.5, }); const edge = new THREE.LineBasicMaterial({ color: 0x19272b, transparent: true, opacity: 0.42, }); makeTwinBox(THREE, root, [2.5, 0.18, 0.72], [0, 0.24, 0.08], structure, edge); const cushion = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 1.82, 7, 24), leather); cushion.rotation.z = Math.PI / 2; cushion.scale.set(0.92, 1, 1.23); cushion.position.set(0, 0.46, 0.08); cushion.castShadow = true; cushion.receiveShadow = true; root.add(cushion); const backrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 1.78, 7, 24), leather); backrest.rotation.z = Math.PI / 2; backrest.scale.set(1.46, 1, 0.43); backrest.position.set(0, 1.04, -0.68); backrest.castShadow = true; root.add(backrest); [-0.79, 0, 0.79].forEach((x) => { const cushionInsert = new THREE.Mesh( new THREE.CapsuleGeometry(0.13, 0.42, 6, 18), insert, ); cushionInsert.rotation.z = Math.PI / 2; cushionInsert.scale.set(0.52, 1, 2.05); cushionInsert.position.set(x, 0.71, 0.08); cushionInsert.castShadow = true; root.add(cushionInsert); const backInsert = new THREE.Mesh( new THREE.CapsuleGeometry(0.18, 0.42, 6, 18), insert, ); backInsert.scale.set(1.65, 1, 0.42); backInsert.position.set(x, 1.05, -0.535); backInsert.castShadow = true; root.add(backInsert); makeCabinBeam(THREE, root, [x - 0.11, 1.43, -0.7], [x - 0.11, 1.55, -0.7], 0.018, structure); makeCabinBeam(THREE, root, [x + 0.11, 1.43, -0.7], [x + 0.11, 1.55, -0.7], 0.018, structure); const headrest = new THREE.Mesh( new THREE.CapsuleGeometry(0.18, 0.16, 7, 18), leather, ); headrest.scale.set(1.25, 1, 0.55); headrest.position.set(x, 1.62, -0.7); headrest.castShadow = true; root.add(headrest); }); [-0.4, 0.4].forEach((x) => { makeTwinBox(THREE, root, [0.09, 0.075, 0.12], [x, 0.73, -0.04], buckleMaterial, edge); }); return root; } function createTwinCabin(THREE, scene) { const cabin = new THREE.Group(); const trim = new THREE.MeshStandardMaterial({ color: 0x25343a, roughness: 0.6, metalness: 0.2, transparent: true, opacity: 0.7, depthWrite: false, }); const softTrim = new THREE.MeshStandardMaterial({ color: 0x506167, roughness: 0.8, transparent: true, opacity: 0.5, depthWrite: false, }); const cockpit = new THREE.MeshStandardMaterial({ color: 0x243238, roughness: 0.6, metalness: 0.18, }); const consoleTop = new THREE.MeshStandardMaterial({ color: 0x5c6c70, roughness: 0.66, metalness: 0.12, }); const accent = new THREE.MeshStandardMaterial({ color: 0x26364c, emissive: 0x78efff, emissiveIntensity: 0.18, roughness: 0.22, metalness: 0.18, depthTest: false, }); const ambientHousingMaterial = new THREE.MeshStandardMaterial({ color: 0x17242c, roughness: 0.38, metalness: 0.56, emissive: 0x08141a, emissiveIntensity: 0.18, }); const ambientMeshes = []; const invisibleEdge = new THREE.LineBasicMaterial({ transparent: true, opacity: 0 }); const shellMaterial = new THREE.MeshPhysicalMaterial({ color: 0x84adb7, roughness: 0.18, metalness: 0.1, transmission: 0.48, transparent: true, opacity: 0.17, depthWrite: false, side: THREE.DoubleSide, clearcoat: 0.9, clearcoatRoughness: 0.18, }); const glassMaterial = new THREE.MeshPhysicalMaterial({ color: 0x6f9eae, roughness: 0.06, transmission: 0.86, transparent: true, opacity: 0.22, depthWrite: false, side: THREE.DoubleSide, }); const shellEdge = new THREE.LineBasicMaterial({ color: 0x456975, transparent: true, opacity: 0.56, }); const roofEntityEdge = new THREE.LineBasicMaterial({ color: 0xbfd5ff, transparent: true, opacity: 1, depthTest: false, }); const ventEntityEdge = new THREE.LineBasicMaterial({ color: 0x9eeaff, transparent: true, opacity: 1, depthTest: false, }); const bodyShell = makeTwinPanel( THREE, cabin, makeVehicleProfileShape(THREE), 3.16, [0, 0, 0], [0, -Math.PI / 2, 0], shellMaterial, shellEdge, ); bodyShell.castShadow = false; bodyShell.receiveShadow = false; bodyShell.renderOrder = 2; const greenhouseMaterial = glassMaterial.clone(); greenhouseMaterial.color.setHex(0x769ead); greenhouseMaterial.opacity = 0.12; const greenhouse = makeTwinPanel( THREE, cabin, makeVehicleGreenhouseShape(THREE), 2.64, [0, 0, 0], [0, -Math.PI / 2, 0], greenhouseMaterial, shellEdge, ); greenhouse.castShadow = false; greenhouse.renderOrder = 3; const bodyPanelMaterial = shellMaterial.clone(); bodyPanelMaterial.color.setHex(0x6f9da8); bodyPanelMaterial.transmission = 0.34; bodyPanelMaterial.opacity = 0.3; const exteriorTrim = new THREE.MeshStandardMaterial({ color: 0x1e2a2f, roughness: 0.52, metalness: 0.3, }); const hood = makeTwinPanel( THREE, cabin, makeRoundedPanelShape(THREE, 2.68, 1.44, 0.18), 0.05, [0, 1.43, 2.88], [-Math.PI / 2 + 0.1, 0, 0], bodyPanelMaterial, shellEdge, ); hood.castShadow = false; hood.renderOrder = 3; makeTwinBox(THREE, cabin, [2.82, 0.42, 0.14], [0, 0.57, 3.57], bodyPanelMaterial, invisibleEdge); makeTwinBox(THREE, cabin, [2.82, 0.38, 0.14], [0, 0.54, -3.55], bodyPanelMaterial, invisibleEdge); makeTwinBox(THREE, cabin, [1.1, 0.18, 0.055], [0, 0.61, 3.655], exteriorTrim, invisibleEdge); makeTwinBox(THREE, cabin, [0.68, 0.055, 0.035], [0, 0.82, -3.635], exteriorTrim, invisibleEdge); for (const side of [-1, 1]) { makeTwinBox(THREE, cabin, [0.12, 0.2, 2.88], [side * 1.5, 0.34, 0.02], bodyPanelMaterial, shellEdge); makeCabinBeam(THREE, cabin, [side * 1.615, 1.3, 1.82], [side * 1.615, 1.3, -2.56], 0.012, exteriorTrim); makeCabinBeam(THREE, cabin, [side * 1.62, 0.4, 0.18], [side * 1.62, 1.29, 0.18], 0.01, exteriorTrim); makeCabinBeam(THREE, cabin, [side * 1.62, 0.38, -1.48], [side * 1.62, 1.29, -1.48], 0.01, exteriorTrim); makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, 0.58], exteriorTrim, invisibleEdge); makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, -1.2], exteriorTrim, invisibleEdge); const mirror = new THREE.Mesh(new THREE.SphereGeometry(0.18, 18, 12), bodyPanelMaterial); mirror.scale.set(0.56, 0.68, 1.24); mirror.position.set(side * 1.76, 1.48, 1.3); mirror.castShadow = true; cabin.add(mirror); makeCabinBeam(THREE, cabin, [side * 1.52, 1.4, 1.31], [side * 1.68, 1.46, 1.3], 0.025, exteriorTrim); } [-1, 1].forEach((side) => { const sideGlass = makeTwinPanel( THREE, cabin, makeVehicleSideGlassShape(THREE), 0.025, [side * 1.335, 0, 0], [0, -Math.PI / 2, 0], glassMaterial, shellEdge, ); sideGlass.castShadow = false; sideGlass.renderOrder = 4; }); const windshield = makeTwinBox( THREE, cabin, [2.48, 1.43, 0.035], [0, 1.88, 1.57], glassMaterial, shellEdge, ); windshield.rotation.x = -0.49; windshield.castShadow = false; windshield.renderOrder = 4; const rearGlass = makeTwinBox( THREE, cabin, [2.38, 1.28, 0.035], [0, 1.88, -2.26], glassMaterial, shellEdge, ); rearGlass.rotation.x = 0.6; rearGlass.castShadow = false; rearGlass.renderOrder = 4; const roofGlassMaterial = glassMaterial.clone(); roofGlassMaterial.color.setHex(0x173778); roofGlassMaterial.emissive.setHex(0x153b82); roofGlassMaterial.emissiveIntensity = 0.46; roofGlassMaterial.transmission = 0; roofGlassMaterial.opacity = 0.98; roofGlassMaterial.depthWrite = true; const roofGlass = makeTwinPanel( THREE, cabin, makeRoundedPanelShape(THREE, 1.08, 1.96, 0.16), 0.038, [0, 2.43, -0.61], [-Math.PI / 2, 0, 0], roofGlassMaterial, roofEntityEdge, ); roofGlass.castShadow = false; roofGlass.renderOrder = 7; const roofFrameMaterial = new THREE.MeshStandardMaterial({ color: 0x94bfff, emissive: 0x326fd2, emissiveIntensity: 0.78, roughness: 0.24, metalness: 0.62, depthTest: false, }); const roofMeshes = [roofGlass]; [ [[1.22, 0.055, 0.075], [0, 2.455, 0.405]], [[1.22, 0.055, 0.075], [0, 2.455, -1.625]], [[0.075, 0.055, 1.96], [-0.59, 2.455, -0.61]], [[0.075, 0.055, 1.96], [0.59, 2.455, -0.61]], ].forEach(([size, position]) => { roofMeshes.push(makeTwinBox( THREE, cabin, size, position, roofFrameMaterial, invisibleEdge, )); }); const roofShadeMaterial = new THREE.MeshStandardMaterial({ color: 0x5f6d71, roughness: 0.86, transparent: true, opacity: 0.06, depthWrite: false, }); const roofShade = makeTwinBox( THREE, cabin, [1.02, 0.025, 1.88], [0, 2.39, -1.55], roofShadeMaterial, invisibleEdge, ); roofShade.scale.z = 0.015; roofShade.renderOrder = 6; for (const side of [-1, 1]) { makeCabinBeam(THREE, cabin, [side * 1.47, 1.25, 1.9], [side * 1.25, 2.51, 1.19], 0.045, trim); makeCabinBeam(THREE, cabin, [side * 1.5, 1.25, -2.55], [side * 1.25, 2.48, -1.78], 0.05, trim); makeCabinBeam(THREE, cabin, [side * 1.56, 1.25, -0.32], [side * 1.34, 2.54, -0.32], 0.045, trim); makeCabinBeam(THREE, cabin, [side * 1.24, 2.57, 1.27], [side * 1.24, 2.57, -1.84], 0.045, trim); } makeCabinBeam(THREE, cabin, [-1.23, 2.58, 1.3], [1.23, 2.58, 1.3], 0.045, trim); makeCabinBeam(THREE, cabin, [-1.23, 2.58, -1.9], [1.23, 2.58, -1.9], 0.045, trim); const cabinFloor = makeTwinBox( THREE, cabin, [2.86, 0.08, 5.45], [0, 0.13, -0.05], new THREE.MeshStandardMaterial({ color: 0x2b393e, roughness: 0.92, transparent: true, opacity: 0.5 }), invisibleEdge, ); cabinFloor.receiveShadow = true; for (const side of [-1, 1]) { makeTwinBox(THREE, cabin, [0.065, 0.62, 3.34], [side * 1.46, 0.67, -0.18], softTrim, invisibleEdge); makeCabinBeam(THREE, cabin, [side * 1.415, 1.02, 1.3], [side * 1.415, 1.02, -1.76], 0.012, trim); } makeTwinBox(THREE, cabin, [0.42, 0.36, 1.55], [0, 0.4, 0.42], cockpit, invisibleEdge); makeTwinBox(THREE, cabin, [0.38, 0.035, 1.28], [0, 0.6, 0.4], consoleTop, invisibleEdge); const dashboard = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 2.55, 7, 28), softTrim); dashboard.rotation.z = Math.PI / 2; dashboard.position.set(0, 1.38, 1.63); dashboard.scale.z = 1.5; dashboard.castShadow = true; cabin.add(dashboard); const gloveboxLightHousing = makeTwinBox( THREE, cabin, [1.18, 0.135, 0.075], [-0.75, 1.265, 1.43], ambientHousingMaterial, invisibleEdge, ); ambientMeshes.push(gloveboxLightHousing); for (let index = 0; index < 9; index += 1) { const lightSegment = makeTwinBox( THREE, cabin, [0.09, 0.042, 0.028], [-1.21 + index * 0.115, 1.315, 1.385], accent, invisibleEdge, ); lightSegment.userData.twinRole = "passenger_glovebox_ambient_light"; ambientMeshes.push(lightSegment); } makeTwinBox(THREE, cabin, [0.54, 0.3, 0.055], [0.82, 1.62, 1.49], cockpit, invisibleEdge); const steeringWheel = new THREE.Group(); const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.036, 10, 36), cockpit); steeringWheel.add(wheel); makeTwinBox(THREE, steeringWheel, [0.035, 0.22, 0.028], [0, -0.1, 0], cockpit, invisibleEdge); makeTwinBox(THREE, steeringWheel, [0.23, 0.035, 0.028], [0, -0.02, 0], cockpit, invisibleEdge); steeringWheel.position.set(0.82, 1.48, 1.24); steeringWheel.rotation.x = -0.34; cabin.add(steeringWheel); makeCabinBeam(THREE, cabin, [0.82, 1.43, 1.27], [0.82, 1.34, 1.57], 0.024, cockpit); const tireMaterial = new THREE.MeshStandardMaterial({ color: 0x172126, roughness: 0.92, metalness: 0.05, }); const rimMaterial = new THREE.MeshStandardMaterial({ color: 0x798a8f, roughness: 0.38, metalness: 0.72, }); [-1, 1].forEach((side) => { [-2.25, 2.35].forEach((z) => { const tire = new THREE.Mesh( new THREE.TorusGeometry(0.53, 0.15, 14, 32), tireMaterial, ); tire.position.set(side * 1.49, 0.58, z); tire.rotation.y = Math.PI / 2; tire.castShadow = true; cabin.add(tire); const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.11, 24), rimMaterial); rim.position.copy(tire.position); rim.rotation.z = Math.PI / 2; cabin.add(rim); const archLip = new THREE.Mesh( new THREE.TorusGeometry(0.69, 0.035, 8, 36, Math.PI), bodyPanelMaterial, ); archLip.position.set(side * 1.615, 0.58, z); archLip.rotation.y = Math.PI / 2; archLip.castShadow = true; cabin.add(archLip); const spokeX = side * 1.555; for (let spoke = 0; spoke < 5; spoke += 1) { const angle = spoke * Math.PI * 2 / 5; makeCabinBeam( THREE, cabin, [spokeX, 0.58, z], [spokeX, 0.58 + Math.cos(angle) * 0.27, z + Math.sin(angle) * 0.27], 0.022, rimMaterial, ); } const hub = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 8), exteriorTrim); hub.position.set(spokeX, 0.58, z); hub.scale.x = 0.45; cabin.add(hub); }); }); const lampMaterial = new THREE.MeshStandardMaterial({ color: 0x697b80, emissive: 0x000000, emissiveIntensity: 0, roughness: 0.56, }); const rearLampMaterial = new THREE.MeshStandardMaterial({ color: 0x697b80, emissive: 0x000000, emissiveIntensity: 0, roughness: 0.56, }); 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); const rearLamp = new THREE.Mesh(new THREE.SphereGeometry(0.15, 18, 10), rearLampMaterial); rearLamp.scale.set(2.35, 0.7, 0.22); rearLamp.position.set(side * 1.08, 1.08, -3.57); cabin.add(rearLamp); } makeTwinBox(THREE, cabin, [1.08, 0.22, 0.035], [0, 0.72, 3.69], cockpit, invisibleEdge); const ventMaterials = []; const ventMeshes = []; const ventCoreMaterial = new THREE.MeshStandardMaterial({ color: 0x07141c, roughness: 0.5, metalness: 0.34, }); const ventBladeMaterial = new THREE.MeshStandardMaterial({ color: 0xd8f2f7, emissive: 0x62dfff, emissiveIntensity: 0.48, roughness: 0.2, metalness: 0.82, depthTest: false, }); [-1.14, -0.34, 0.34, 1.14].forEach((x) => { const ventMaterial = new THREE.MeshStandardMaterial({ color: 0x176c94, roughness: 0.24, metalness: 0.55, emissive: 0x2ccff3, emissiveIntensity: 0.62, }); ventMaterials.push(ventMaterial); const vent = makeTwinBox( THREE, cabin, [Math.abs(x) > 1 ? 0.47 : 0.44, 0.2, 0.135], [x, 1.38, 1.405], ventMaterial, ventEntityEdge, ); vent.userData.twinRole = "dashboard_hvac_outlet"; ventMeshes.push(vent); const coreWidth = Math.abs(x) > 1 ? 0.365 : 0.34; makeTwinBox( THREE, vent, [coreWidth, 0.12, 0.145], [0, 0, 0], ventCoreMaterial, invisibleEdge, ); for (const bladeY of [-0.038, 0, 0.038]) { makeTwinBox( THREE, vent, [coreWidth * 0.9, 0.014, 0.025], [0, bladeY, -0.083], ventBladeMaterial, invisibleEdge, ); } }); const rearVentMaterial = new THREE.MeshStandardMaterial({ color: 0x203741, roughness: 0.34, metalness: 0.42, emissive: 0x2d91b6, emissiveIntensity: 0.3, }); ventMaterials.push(rearVentMaterial); [-0.1, 0.1].forEach((x) => { const vent = makeTwinBox( THREE, cabin, [0.18, 0.13, 0.065], [x, 0.48, -0.375], rearVentMaterial, ventEntityEdge, ); ventMeshes.push(vent); }); const airflow = createTwinAirflowField(THREE, cabin); const ambientLights = [ new THREE.PointLight(0x74d9ff, 0, 2.2, 2), new THREE.PointLight(0x74d9ff, 0, 2.2, 2), ]; ambientLights[0].position.set(-1.06, 1.22, 1.08); ambientLights[1].position.set(-0.48, 1.22, 1.08); ambientLights.forEach((light) => cabin.add(light)); const fallbackExteriorMaterials = new Set([ shellMaterial, greenhouseMaterial, bodyPanelMaterial, exteriorTrim, glassMaterial, tireMaterial, lampMaterial, rearLampMaterial, ]); const fallbackExteriorObjects = []; const fallbackInteriorMaterials = new Set([trim, softTrim, cockpit, consoleTop]); const fallbackInteriorObjects = []; const worldPosition = new THREE.Vector3(); cabin.updateMatrixWorld(true); cabin.traverse((object) => { if (!object.isMesh) return; const isFallbackMaterial = fallbackExteriorMaterials.has(object.material); object.getWorldPosition(worldPosition); const isWheelDetail = ( object.material === rimMaterial && Math.abs(worldPosition.z) > 1.8 ); if (isFallbackMaterial || isWheelDetail) fallbackExteriorObjects.push(object); if (fallbackInteriorMaterials.has(object.material) || object === cabinFloor) { fallbackInteriorObjects.push(object); } }); scene.add(cabin); return { root: cabin, fallbackExteriorObjects, fallbackInteriorObjects, shellMaterial, glassMaterial, roofGlass, roofMeshes, roofGlassMaterial, roofShade, roofShadeMaterial, ambientMaterial: accent, ambientMeshes, ambientLights, ventMaterials, ventMeshes, airflow, colors: { ambientTarget: new THREE.Color(0x74d9ff), ambientIdle: new THREE.Color(0x17323a), climateTarget: new THREE.Color(0x55bdec), roofClear: new THREE.Color(0x1f68b6), roofDark: new THREE.Color(0x071b3e), }, targets: { ambientPower: 0, ambientBrightness: 0, climateLevel: 0, climateTemperature: 24, roofTransmittance: 100, roofShadePosition: 0, }, current: { ambientBrightness: 0, climateLevel: 0, roofTransmittance: 100, roofShadePosition: 0, }, layout: { roofShadeCenterZ: -0.61, roofShadeHalfLength: 0.94, }, }; } function updateTwinVehicleVisual(systems, timestamp) { if (!systems) return; const ease = (current, target, factor = 0.08) => ( Math.abs(target - current) < 0.002 ? target : current + (target - current) * factor ); systems.current.ambientBrightness = ease( systems.current.ambientBrightness, systems.targets.ambientPower ? systems.targets.ambientBrightness : 0, ); systems.current.climateLevel = ease( systems.current.climateLevel, systems.targets.climateLevel, ); systems.current.roofTransmittance = ease( systems.current.roofTransmittance, systems.targets.roofTransmittance, ); systems.current.roofShadePosition = ease( systems.current.roofShadePosition, systems.targets.roofShadePosition, ); const ambientStrength = systems.current.ambientBrightness / 100; const ambientDisplayColor = systems.colors.ambientIdle.clone().lerp( systems.colors.ambientTarget, 0.34 + ambientStrength * 0.66, ); systems.ambientMaterial.color.lerp(ambientDisplayColor, 0.1); systems.ambientMaterial.emissive.lerp(systems.colors.ambientTarget, 0.1); systems.ambientMaterial.emissiveIntensity = 0.28 + ambientStrength * 2.15; systems.ambientMaterial.transparent = false; systems.ambientMaterial.opacity = 1; systems.ambientMaterial.depthWrite = true; systems.ambientLights.forEach((light) => { light.color.lerp(systems.colors.ambientTarget, 0.1); light.intensity = ambientStrength * 2.4; }); const roofRatio = systems.current.roofTransmittance / 100; const roofColor = systems.colors.roofDark.clone().lerp(systems.colors.roofClear, roofRatio); systems.roofGlassMaterial.color.lerp(roofColor, 0.08); systems.roofGlassMaterial.opacity = 0.86 + (1 - roofRatio) * 0.1; 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; const climateStrength = systems.current.climateLevel / 7; const climateColor = systems.targets.climateTemperature < 24 ? 0x55bdec : systems.targets.climateTemperature > 24 ? 0xffa05d : 0x72d1d0; systems.colors.climateTarget.setHex(climateColor); systems.airflow.level = ease(systems.airflow.level, climateStrength, 0.1); systems.airflow.material.color.lerp(systems.colors.climateTarget, 0.12); systems.airflow.material.opacity = systems.airflow.level * 0.92; systems.airflow.material.size = 0.04 + systems.airflow.level * 0.055; systems.airflow.points.visible = systems.airflow.level > 0.01; systems.ventMaterials.forEach((material) => { material.transparent = false; material.opacity = 1; material.depthWrite = true; material.emissive.lerp(systems.colors.climateTarget, 0.12); material.emissiveIntensity = 0.3 + systems.airflow.level * 2; }); const speed = 0.00018 + systems.airflow.level * 0.00042; systems.airflow.seeds.forEach((seed, index) => { const progress = (timestamp * speed + seed) % 1; const lane = index % 8; const offset = index * 3; systems.airflow.positions[offset] = -1.22 + lane * 0.13 + Math.sin(progress * 9 + index) * 0.035; systems.airflow.positions[offset + 1] = 1.35 - progress * 0.5 + Math.sin(progress * Math.PI) * 0.13; systems.airflow.positions[offset + 2] = 1.38 - progress * 1.68; }); systems.airflow.attribute.needsUpdate = true; } function makeTwinPanel(THREE, parent, shape, depth, position, rotation, material, edgeMaterial) { const geometry = new THREE.ExtrudeGeometry(shape, { depth, steps: 1, curveSegments: 18, bevelEnabled: true, bevelSegments: 4, bevelSize: 0.045, bevelThickness: 0.04, }); geometry.translate(0, 0, -depth / 2); geometry.computeVertexNormals(); const mesh = new THREE.Mesh(geometry, material); mesh.position.set(...position); mesh.rotation.set(...rotation); mesh.castShadow = true; mesh.receiveShadow = true; const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 48), edgeMaterial); mesh.add(edges); parent.add(mesh); return mesh; } function makeBackrestShape(THREE, inset = 0) { const shape = new THREE.Shape(); const half = 0.7 - inset; shape.moveTo(-0.52 + inset, 0); shape.quadraticCurveTo(-half, 0.08, -half, 0.34); shape.lineTo(-half + 0.035, 1.28 - inset); shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset); shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset); shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset); shape.lineTo(half, 0.34); shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0); shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0); return shape; } function makeCushionShape(THREE, inset = 0) { const shape = new THREE.Shape(); const half = 0.69 - inset; shape.moveTo(-0.52 + inset, 0.61 - inset); shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34); shape.lineTo(-half + 0.025, -0.39 + inset); shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset); shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset); shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset); shape.lineTo(half, 0.34); shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset); shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset); return shape; } function makeTwinSeam(THREE, parent, points, material) { const geometry = new THREE.BufferGeometry().setFromPoints( points.map(([x, y, z]) => new THREE.Vector3(x, y, z)), ); const seam = new THREE.Line(geometry, material); parent.add(seam); return seam; } function createTwinVentField(THREE, parent, kind) { const count = 12; const positions = new Float32Array(count * 3); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); const material = new THREE.PointsMaterial({ color: 0x39bdf2, size: 0.055, sizeAttenuation: true, transparent: true, opacity: 0, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, }); const points = new THREE.Points(geometry, material); points.frustumCulled = false; points.renderOrder = 18; parent.add(points); return { kind, points, positions, attribute: geometry.getAttribute("position"), material, }; } function createTwinHeatPulse(THREE, parent, kind, phase) { const material = new THREE.MeshBasicMaterial({ color: 0xff633f, transparent: true, opacity: 0, depthWrite: false, depthTest: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, }); const mesh = new THREE.Mesh( new THREE.RingGeometry(0.17, 0.205, 32), material, ); if (kind === "backrest") { mesh.position.set(0, 0.88, 0.285); mesh.scale.set(1.8, 3, 1); } else { mesh.position.set(0, 0.205, 0.04); mesh.rotation.x = -Math.PI / 2; mesh.scale.set(1.75, 2.35, 1); } mesh.visible = false; mesh.renderOrder = 17; parent.add(mesh); return { mesh, material, phase, baseScale: mesh.scale.clone() }; } function updateTwinThermalVisual(model, timestamp) { const smoothing = 0.075; ["ventilation", "heating"].forEach((feature) => { const delta = model.thermalTargets[feature] - model.thermalLevels[feature]; model.thermalLevels[feature] += Math.abs(delta) < 0.002 ? delta : delta * smoothing; }); const ventilation = model.thermalLevels.ventilation; const heating = model.thermalLevels.heating; const selectionFactor = model.isSelected ? 1 : 0.58; const targetColor = model.thermalColors.base.clone(); targetColor.lerp(model.thermalColors.ventilation, ventilation * 0.5); targetColor.lerp(model.thermalColors.heating, heating * 0.6); model.insertMaterial.color.lerp(targetColor, 0.1); const targetEmissive = model.thermalColors.ventilation.clone().multiplyScalar( ventilation * 0.14, ); targetEmissive.lerp( model.thermalColors.heating, Math.min(1, heating * 0.34), ); model.insertMaterial.emissive.lerp(targetEmissive, 0.1); model.insertMaterial.emissiveIntensity = 0.12 + Math.max(ventilation, heating) * 0.38; const elapsed = timestamp * (0.0003 + ventilation * 0.00018); model.ventFields.forEach((field, fieldIndex) => { field.points.visible = ventilation > 0.015; field.material.opacity = ventilation * selectionFactor * 0.92; field.material.size = 0.045 + ventilation * 0.045; for (let index = 0; index < field.positions.length / 3; index += 1) { const progress = (elapsed + index / 12 + fieldIndex * 0.17) % 1; const column = (index % 3) - 1; const row = Math.floor(index / 3); const offset = index * 3; if (field.kind === "backrest") { field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025; field.positions[offset + 1] = 0.27 + progress * 1.28; field.positions[offset + 2] = 0.29 + Math.sin(progress * Math.PI) * 0.22; } else { field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025; field.positions[offset + 1] = 0.205 + progress * 0.36; field.positions[offset + 2] = -0.36 + row * 0.24 + Math.sin(progress * Math.PI) * 0.05; } } field.attribute.needsUpdate = true; }); model.heatPulses.forEach((pulse) => { const progress = (timestamp * 0.00042 + pulse.phase) % 1; const scale = 0.78 + progress * 0.42; pulse.mesh.visible = heating > 0.015; pulse.mesh.scale.copy(pulse.baseScale).multiplyScalar(scale); pulse.material.opacity = heating * selectionFactor * (1 - progress) * 0.38; }); } function createTwinSeat(THREE, x, accentColor) { const root = new THREE.Group(); root.position.set(x, 0.14, 0.05); const shell = new THREE.MeshPhysicalMaterial({ color: 0x344950, roughness: 0.5, metalness: 0.01, clearcoat: 0.16, clearcoatRoughness: 0.72, }); const insert = new THREE.MeshPhysicalMaterial({ color: 0x9fb1b2, roughness: 0.68, metalness: 0, clearcoat: 0.08, }); const thermalInsert = insert.clone(); const bolster = new THREE.MeshStandardMaterial({ color: 0x52686f, roughness: 0.72 }); const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.64, metalness: 0.32 }); const rail = new THREE.MeshStandardMaterial({ color: 0x65757a, roughness: 0.45, metalness: 0.7 }); const edge = new THREE.LineBasicMaterial({ color: 0x182529, transparent: true, opacity: 0.34 }); const seam = new THREE.LineBasicMaterial({ color: accentColor, transparent: true, opacity: 0.88 }); const trackedMaterials = [shell, insert, thermalInsert, bolster]; makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], rail, edge); makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [0.48, 0.12, 0], rail, edge); makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structure, edge); makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structure, edge); const slide = new THREE.Group(); root.add(slide); const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structure); base.rotation.x = Math.PI / 2; base.scale.set(1.42, 1, 0.55); base.position.set(0, 0.34, -0.02); base.castShadow = true; base.receiveShadow = true; slide.add(base); const cushionPivot = new THREE.Group(); cushionPivot.position.set(0, 0.57, 0.06); slide.add(cushionPivot); makeTwinPanel( THREE, cushionPivot, makeCushionShape(THREE), 0.22, [0, 0, 0.08], [-Math.PI / 2, 0, 0], shell, edge, ); makeTwinPanel( THREE, cushionPivot, makeCushionShape(THREE, 0.16), 0.055, [0, 0.145, 0.03], [-Math.PI / 2, 0, 0], thermalInsert, edge, ); for (const side of [-1, 1]) { const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolster); wing.rotation.x = Math.PI / 2; wing.position.set(side * 0.61, 0.17, 0.08); wing.castShadow = true; cushionPivot.add(wing); } makeTwinSeam(THREE, cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]], seam); makeTwinSeam(THREE, cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]], seam); const cushionVentField = createTwinVentField(THREE, cushionPivot, "cushion"); const backrestPivot = new THREE.Group(); backrestPivot.position.set(0, 0.62, -0.5); slide.add(backrestPivot); makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE), 0.3, [0, 0, 0], [0, 0, 0], shell, edge); makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE, 0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert, edge); for (const side of [-1, 1]) { const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolster); wing.position.set(side * 0.61, 0.78, 0.18); wing.scale.set(1, 1, 0.72); wing.castShadow = true; backrestPivot.add(wing); } makeTwinSeam(THREE, backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]], seam); makeTwinSeam(THREE, backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]], seam); makeTwinSeam(THREE, backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]], seam); const backrestVentField = createTwinVentField(THREE, backrestPivot, "backrest"); const heatPulses = [ createTwinHeatPulse(THREE, cushionPivot, "cushion", 0), createTwinHeatPulse(THREE, cushionPivot, "cushion", 0.5), createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.18), createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.68), ]; makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], rail, edge); makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], rail, edge); const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), shell); headrest.scale.set(1.35, 1, 0.5); headrest.position.set(0, 1.98, 0.01); headrest.castShadow = true; backrestPivot.add(headrest); const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), insert); headrestInsert.scale.set(1.32, 1, 0.28); headrestInsert.position.set(0, 1.98, 0.18); headrestInsert.castShadow = true; backrestPivot.add(headrestInsert); const legrestPivot = new THREE.Group(); legrestPivot.position.set(0, 0.64, 0.7); slide.add(legrestPivot); const legrestExtension = new THREE.Group(); legrestPivot.add(legrestExtension); makeTwinPanel( THREE, legrestExtension, makeCushionShape(THREE, 0.08), 0.18, [0, 0, 0.36], [-Math.PI / 2, 0, 0], shell, edge, ).scale.set(0.9, 0.72, 0.54); makeTwinPanel( THREE, legrestExtension, makeCushionShape(THREE, 0.2), 0.045, [0, 0.12, 0.4], [-Math.PI / 2, 0, 0], insert, edge, ).scale.set(0.92, 0.74, 0.56); const outerSide = Math.sign(x); const anchors = {}; const addAnchor = (name, parent, position) => { const anchor = new THREE.Object3D(); anchor.position.set(...position); parent.add(anchor); anchors[name] = anchor; }; addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]); addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]); addAnchor("backrest_recline", backrestPivot, [outerSide * 0.7, 1.28, 0.2]); addAnchor("legrest_height", legrestExtension, [outerSide * 0.7, 0.08, 0.58]); addAnchor("legrest_extension", legrestExtension, [outerSide * 0.7, 0.1, 0.82]); addAnchor("backrest_included_angle", slide, [outerSide * 0.83, 0.62, -0.5]); addAnchor("legrest_included_angle", slide, [-outerSide * 0.7, 0.51, 0.7]); const angleGuides = { backrest_recline: createTwinAngleGuide(THREE, slide, accentColor), legrest_height: createTwinAngleGuide(THREE, slide, accentColor), }; const defaultAngles = { backrest_recline: twinAngleForPosition( "backrest_recline", TWIN_DEFAULT_POSITIONS.backrest_recline, ), legrest_height: twinAngleForPosition( "legrest_height", TWIN_DEFAULT_POSITIONS.legrest_height, ), }; return { root, slide, cushionPivot, backrestPivot, legrestPivot, legrestExtension, anchors, angleGuides, outerSide, trackedMaterials, materials: { shell, insert, thermalInsert, bolster, structure, rail, edge, seam, }, insertMaterial: thermalInsert, isSelected: false, thermalTargets: { ventilation: 0, heating: 0 }, thermalLevels: { ventilation: 0, heating: 0 }, thermalColors: { base: new THREE.Color(0x9fb1b2), ventilation: new THREE.Color(0x4b93b7), heating: new THREE.Color(0xd05f43), }, ventFields: [cushionVentField, backrestVentField], heatPulses, positions: { ...TWIN_DEFAULT_POSITIONS }, targets: { ...TWIN_DEFAULT_POSITIONS }, angles: { ...defaultAngles }, angleTargets: { ...defaultAngles }, }; } function configureFixedTwinSeat(THREE, model, position, role) { const cushionAngle = 6.5; const backrestIncludedAngle = 104; const legrestIncludedAngle = 95; model.root.position.set(...position); model.root.scale.setScalar(0.84); model.root.userData.twinRole = role; model.slide.position.z = 0; model.cushionPivot.rotation.x = THREE.MathUtils.degToRad(-cushionAngle); model.backrestPivot.rotation.x = ( model.cushionPivot.rotation.x - THREE.MathUtils.degToRad(backrestIncludedAngle - 90) ); model.legrestPivot.rotation.x = THREE.MathUtils.degToRad( 180 - cushionAngle - legrestIncludedAngle, ); model.legrestExtension.position.z = 0; Object.values(model.angleGuides).forEach((guide) => { guide.group.visible = false; }); model.ventFields.forEach((field) => { field.points.visible = false; }); model.heatPulses.forEach((pulse) => { pulse.mesh.visible = false; }); return model; } function setTwinSelection(selected) { const twin = state.seatTwin; if (!twin) return; const focusedSeat = "passenger"; twin.selected = focusedSeat; $("#seatTwin").dataset.selectedSeat = focusedSeat; $$(".twin-seat-button").forEach((button) => { button.classList.toggle("active", button.dataset.twinSeat === focusedSeat); }); Object.entries(twin.models).forEach(([seat, model]) => { const isSelected = seat === focusedSeat; model.isSelected = isSelected; Object.entries(model.angleGuides).forEach(([axis, guide]) => { guide.group.visible = ( isSelected && twin.explicitAngleTargets[focusedSeat]?.[axis] != null ); }); model.trackedMaterials.forEach((material) => { material.transparent = false; material.opacity = 1; material.depthWrite = true; material.depthTest = true; material.needsUpdate = true; }); }); twin.view.targetX = 0; twin.view.yaw = -0.62; renderTwinReadout(); } function renderTwinCalloutValues(device) { const value = (axis, fallback) => Number(device?.position?.[axis]?.position_pct ?? fallback); const angle = (axis, fallbackPosition) => Number( device?.position?.[axis]?.angle_deg ?? twinAngleForPosition(axis, value(axis, fallbackPosition)), ); const longitudinal = value("longitudinal", 50); const cushion = value("cushion_tilt", 50); const backrest = value("backrest_recline", 20); const legrest = value("legrest_height", 0); const extension = value("legrest_extension", 0); const postureAngles = twinPostureAngles(device); const explicitAngles = state.seatTwin?.explicitAngleTargets[ state.seatTwin.selected ] || {}; const backrestEstimated = isEstimatedMotion(device, "backrest_recline"); const legrestEstimated = isEstimatedMotion(device, "legrest_height"); const formatAngle = (number) => ( Number.isInteger(number) ? String(number) : number.toFixed(1) ); const values = { longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "前移区" : longitudinal > 55 ? "后移区" : "中位区"], cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`], backrest_recline: [ `${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`, `${backrestEstimated ? "估算 " : ""}${backrest}% · 相对归零位`, ], legrest_height: [ `${legrestEstimated ? "≈" : ""}${formatAngle(postureAngles.legrest_height)}°`, `${legrestEstimated ? "估算 " : ""}${legrest}% · 相对垂直零位`, ], legrest_extension: [`${extension}%`, "伸缩行程"], backrest_included_angle: [ `${backrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.backrest_recline ?? angle("backrest_recline", 20))}°`, backrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角", ], legrest_included_angle: [ `${legrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.legrest_height ?? angle("legrest_height", 0))}°`, legrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角", ], }; Object.entries(values).forEach(([axis, [primary, secondary]]) => { const callout = $(`[data-callout="${axis}"]`); $("b", callout).textContent = primary; $("small", callout).textContent = secondary; }); } function renderTwinReadout() { const twin = state.seatTwin; if (!twin) return; const seat = "passenger"; const device = twin.actuatorState.passenger; $("#twinSeatName").textContent = "副驾"; $("#twinSeatZone").textContent = "front_right"; const postureAngles = twinPostureAngles(device); Object.entries(TWIN_DEFAULT_POSITIONS).forEach(([axis, fallback]) => { const motion = device?.position?.[axis]; const position = Number(motion?.position_pct ?? fallback); const postureAngle = postureAngles[axis]; const estimatedPrefix = isEstimatedMotion(device, axis) ? "≈" : ""; const row = $(`[data-twin-axis="${axis}"]`); $("b", row).textContent = postureAngle == null ? `${estimatedPrefix}${position}%` : `${estimatedPrefix}${position}% · ${Number(postureAngle).toFixed(Number.isInteger(Number(postureAngle)) ? 0 : 1)}°`; row.style.setProperty("--value", `${Math.max(0, Math.min(100, position))}%`); row.classList.toggle("fault", Boolean(device?.position?.[axis]?.fault)); }); ["ventilation", "heating"].forEach((feature) => { const value = device?.[feature] || { state: "off", level: 0 }; const item = $(`[data-twin-function="${feature}"]`); const enabled = value.state === "on"; const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0; item.classList.toggle("on", enabled); item.dataset.level = String(level); $("b", item).textContent = enabled ? `${level}档` : "关闭"; }); renderTwinCalloutValues(device); } function updateTwinExplicitAngles(result) { const twin = state.seatTwin; if (!twin) return; const targets = { passenger: {} }; const requestedSeats = new Set(); const capabilityAxes = { "seat.set_backrest_recline": "backrest_recline", "seat.set_legrest_height": "legrest_height", }; if (["succeeded", "partially_succeeded"].includes(result.status)) { (result.model_requests || []).forEach((request) => { const axis = capabilityAxes[request.capability]; const target = request.target_scope; if (targets[target]) requestedSeats.add(target); if (!axis || !targets[target]) return; const parameter = (request.parameters || []).find( (item) => item.name === "angle_deg" && Number.isFinite(Number(item.value)), ); if (parameter) targets[target][axis] = Number(parameter.value); }); } twin.explicitAngleTargets = targets; setTwinSelection("passenger"); } function updateSeatTwin(actuatorState) { const twin = state.seatTwin; if (!twin || !actuatorState) return; twin.actuatorState = actuatorState; Object.entries(twin.models).forEach(([seat, model]) => { Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => { const motion = actuatorState[seat]?.position?.[axis]; const value = motion?.position_pct; if (Number.isFinite(Number(value))) model.targets[axis] = Number(value); if (TWIN_ANGLE_RANGES[axis]) { const angle = motion?.angle_deg ?? twinAngleForPosition(axis, value); if (Number.isFinite(Number(angle))) model.angleTargets[axis] = Number(angle); } }); ["ventilation", "heating"].forEach((feature) => { const value = actuatorState[seat]?.[feature]; const enabled = value?.state === "on"; const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0; model.thermalTargets[feature] = level / 3; }); }); renderTwinReadout(); } const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/"; const REFERENCE_VEHICLE_URL = `${REFERENCE_VEHICLE_ROOT}byd-tang-2024.fbx`; function referenceMaterialDescriptor(material) { const textureNames = [ "map", "aoMap", "alphaMap", "normalMap", "roughnessMap", "metalnessMap", "emissiveMap", ].map((key) => { const image = material?.[key]?.image; return image?.currentSrc || image?.src || image?.name || ""; }); return [material?.name || "", ...textureNames].join(" ").toLowerCase(); } function classifyReferenceMaterial(material) { const descriptor = referenceMaterialDescriptor(material); if (/shadow/.test(descriptor)) return "shadow"; if (/tyre|tire|wheel|alloy|caliper/.test(descriptor)) return "wheel"; if (/interior|seat|dashboard|cockpit|steer/.test(descriptor)) return "interior"; if (/(^|[_\W])(hl|tl|fl)([_\W]|$)|headlight|taillight|lamp|light|fl_emiss|rear_emiss/.test(descriptor)) return "light"; if (/glass|window|windshield/.test(descriptor)) return "glass"; if (/body|paint|carpaint/.test(descriptor)) return "body"; return "detail"; } const TWIN_OUTLINE_STYLE = { body: { color: 0x315d69, lineOpacity: 0.36, occludedColor: 0xa7b8bb, occludedLineOpacity: 0.12, fillOpacity: 0.11, thresholdAngle: 58, renderOrder: 30, }, glass: { color: 0x78a7b2, lineOpacity: 0, fillOpacity: 0.045, renderOrder: 31 }, detail: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.028, renderOrder: 32 }, wheel: { color: 0x203d45, lineOpacity: 0.52, occludedColor: 0x9eafb2, occludedLineOpacity: 0.14, fillOpacity: 0.18, thresholdAngle: 50, renderOrder: 33, }, interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 }, light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, renderOrder: 32 }, }; function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) { if ( !mesh.geometry?.attributes?.position || mesh.userData.twinOutline || style.lineOpacity <= 0 ) return null; const geometry = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle); if (!geometry.getAttribute("position")?.count) { geometry.dispose(); return null; } const visibleMaterial = new THREE.LineBasicMaterial({ color: style.color, transparent: true, opacity: style.lineOpacity, depthWrite: false, depthTest: true, depthFunc: THREE.LessEqualDepth, toneMapped: false, }); visibleMaterial.onBeforeCompile = (shader) => { shader.vertexShader = shader.vertexShader.replace( "#include ", "#include \ngl_Position.z -= 0.0006 * gl_Position.w;", ); }; visibleMaterial.customProgramCacheKey = () => "twin-visible-line-depth-bias-v1"; const visibleOutline = new THREE.LineSegments(geometry, visibleMaterial); visibleOutline.name = `${mesh.name || "mesh"}_outline_visible`; visibleOutline.renderOrder = style.renderOrder + 1; visibleOutline.frustumCulled = mesh.frustumCulled; mesh.add(visibleOutline); mesh.userData.twinOutline = visibleOutline; const occludedOpacity = style.occludedLineOpacity ?? 0; if (occludedOpacity > 0) { const occludedMaterial = new THREE.LineBasicMaterial({ color: style.occludedColor ?? style.color, transparent: true, opacity: occludedOpacity, depthWrite: false, depthTest: true, depthFunc: THREE.GreaterDepth, toneMapped: false, }); const occludedOutline = new THREE.LineSegments(geometry, occludedMaterial); occludedOutline.name = `${mesh.name || "mesh"}_outline_occluded`; occludedOutline.renderOrder = style.renderOrder; occludedOutline.frustumCulled = mesh.frustumCulled; mesh.add(occludedOutline); mesh.userData.twinOccludedOutline = occludedOutline; } return visibleOutline; } function applyTwinOutlineStyle(THREE, root, options = {}) { const style = { color: options.color ?? 0x52727a, lineOpacity: options.lineOpacity ?? 0.58, fillOpacity: options.fillOpacity ?? 0.014, renderOrder: options.renderOrder ?? 40, }; const meshes = []; root.traverse((object) => { if (object.isMesh && object.visible) meshes.push(object); }); meshes.forEach((mesh) => { const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; materials.filter(Boolean).forEach((material) => { material.transparent = true; material.opacity = Math.min(Number(material.opacity ?? 1), style.fillOpacity); material.depthWrite = false; material.side = THREE.DoubleSide; material.needsUpdate = true; }); mesh.castShadow = false; mesh.receiveShadow = false; mesh.renderOrder = style.renderOrder - 1; addTwinMeshOutline(THREE, mesh, style, options.thresholdAngle ?? 28); }); } function configureTwinSolidEntity(THREE, roots, options = {}) { const items = Array.isArray(roots) ? roots : [roots]; items.filter(Boolean).forEach((root) => { root.traverse((object) => { if (!object.isMesh || !object.visible) return; if (object.userData.twinOutline) object.userData.twinOutline.visible = false; if (object.userData.twinOccludedOutline) { object.userData.twinOccludedOutline.visible = false; } const materials = Array.isArray(object.material) ? object.material : [object.material]; materials.filter(Boolean).forEach((material) => { material.transparent = Boolean(options.transparent); material.opacity = options.opacity ?? 1; material.depthWrite = options.depthWrite ?? !options.transparent; material.side = THREE.DoubleSide; material.needsUpdate = true; }); object.castShadow = true; object.receiveShadow = true; object.renderOrder = options.renderOrder ?? 46; }); }); } function configureActivePassengerSeatStyle(model) { const { shell, insert, thermalInsert, bolster, structure, rail, edge, seam, } = model.materials; shell.color.setHex(0xd85b27); shell.roughness = 0.3; shell.clearcoat = 0.58; shell.emissive.setHex(0x64210c); shell.emissiveIntensity = 0.24; insert.color.setHex(0xffe0a3); insert.emissive.setHex(0x6d4315); insert.emissiveIntensity = 0.2; thermalInsert.color.setHex(0xffc866); thermalInsert.emissive.setHex(0x7a3d0d); thermalInsert.emissiveIntensity = 0.24; bolster.color.setHex(0xb93426); bolster.emissive.setHex(0x53120d); bolster.emissiveIntensity = 0.2; structure.color.setHex(0x39231d); structure.emissive.setHex(0x1b0d09); structure.emissiveIntensity = 0.14; rail.color.setHex(0xa8bdc5); rail.metalness = 0.82; [shell, insert, thermalInsert, bolster, structure, rail].forEach((material) => { material.transparent = false; material.opacity = 1; material.depthWrite = true; material.depthTest = true; material.alphaTest = 0; material.colorWrite = true; material.toneMapped = false; if ("transmission" in material) material.transmission = 0; material.needsUpdate = true; }); edge.color.setHex(0xffd592); edge.opacity = 1; edge.depthTest = false; seam.color.setHex(0xfff1cd); seam.opacity = 1; seam.depthTest = false; model.thermalColors.base.setHex(0xffc866); model.root.traverse((object) => { if (!object.isLine && !object.isLineSegments) return; object.renderOrder = 55; }); } function visibleTwinMeshBounds(THREE, root) { const bounds = new THREE.Box3(); const meshBounds = new THREE.Box3(); root.updateWorldMatrix(true, true); root.traverse((object) => { if (!object.isMesh || !object.visible || !object.geometry?.attributes?.position) return; if (!object.geometry.boundingBox) object.geometry.computeBoundingBox(); meshBounds.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld); bounds.union(meshBounds); }); return bounds; } function configureReferenceVehicleModel(THREE, vehicle) { const materialCache = new Map(); const report = { meshes: 0, materials: new Map(), nodes: [] }; vehicle.traverse((object) => { if (object.name) report.nodes.push(object.name); if (!object.isMesh) return; report.meshes += 1; object.castShadow = false; object.receiveShadow = false; const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material]; const configuredMaterials = sourceMaterials.map((sourceMaterial) => { if (!sourceMaterial) return sourceMaterial; if (materialCache.has(sourceMaterial)) return materialCache.get(sourceMaterial); const material = sourceMaterial.clone(); const descriptor = referenceMaterialDescriptor(material); const category = classifyReferenceMaterial(material); const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail; const key = `${category}:${material.name || "unnamed"}`; report.materials.set(key, (report.materials.get(key) || 0) + 1); [ "map", "aoMap", "bumpMap", "normalMap", "roughnessMap", "metalnessMap", "emissiveMap", ].forEach((textureKey) => { if (textureKey in material) material[textureKey] = null; }); if (material.emissive) { material.emissive.setHex(0x000000); if ("emissiveIntensity" in material) material.emissiveIntensity = 0; } if (category === "glass") { if (material.color) material.color.setHex(0x55727b); material.transparent = true; material.opacity = 0.3; material.depthWrite = false; material.side = THREE.DoubleSide; object.castShadow = false; object.renderOrder = 12; } else if (category === "body") { if (material.color) material.color.setHex(0x829398); if ("shininess" in material) material.shininess = 72; } else if (category === "light") { material.map = null; if (material.color) material.color.setHex(0x697b80); if (material.emissive) material.emissive.setHex(0x000000); if ("emissiveIntensity" in material) material.emissiveIntensity = 0; } else if (category === "interior" && material.color) { material.color.setHex(0x6c7778); } else if (/lines|\badd\b/.test(descriptor) && material.color) { material.map = null; material.color.setHex(0x4c5a5d); } else if (/map[_ -]?[abc]/.test(descriptor) && material.color) { if (/chrome|metal/.test(descriptor)) material.color.setHex(0xaeb9bb); else if (/plastic/.test(descriptor)) material.color.setHex(0x303a3e); else material.color.setHex(0x7f8e91); } else if (/plastic|under|black/.test(descriptor) && material.color) { material.color.setHex(0x303a3e); } else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) { material.color.setHex(0xaeb9bb); } material.transparent = true; material.opacity = outlineStyle.fillOpacity; material.depthWrite = ["body", "detail", "wheel"].includes(category); material.depthTest = true; material.side = category === "glass" ? THREE.DoubleSide : THREE.FrontSide; material.wireframe = false; if ("flatShading" in material) material.flatShading = false; if ("roughness" in material) material.roughness = category === "body" ? 0.72 : 0.82; if ("metalness" in material) material.metalness = category === "wheel" ? 0.48 : 0.08; if (material.emissive) material.emissive.setHex(0x000000); if ("emissiveIntensity" in material) material.emissiveIntensity = 0; material.needsUpdate = true; materialCache.set(sourceMaterial, material); return material; }); object.material = Array.isArray(object.material) ? configuredMaterials : configuredMaterials[0]; const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial); if (categories.length && categories.every((category) => category === "shadow")) object.visible = false; if (!object.visible) return; const categoryPriority = ["light", "interior", "wheel", "detail", "glass", "body"]; 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, ); }); return report; } function normalizeReferenceVehicle(THREE, vehicle) { vehicle.updateMatrixWorld(true); let bounds = new THREE.Box3().setFromObject(vehicle); let size = bounds.getSize(new THREE.Vector3()); if (size.x > size.z) { vehicle.rotateY(Math.PI / 2); vehicle.updateMatrixWorld(true); bounds = new THREE.Box3().setFromObject(vehicle); size = bounds.getSize(new THREE.Vector3()); } const scale = 7.45 / Math.max(size.x, size.z); vehicle.scale.multiplyScalar(scale); vehicle.updateMatrixWorld(true); bounds = new THREE.Box3().setFromObject(vehicle); const center = bounds.getCenter(new THREE.Vector3()); vehicle.position.x -= center.x; vehicle.position.z -= center.z; vehicle.position.y += -0.145 - bounds.min.y; vehicle.updateMatrixWorld(true); bounds = new THREE.Box3().setFromObject(vehicle); return { bounds, size: bounds.getSize(new THREE.Vector3()), scale }; } function measureReferenceDriverCushion(THREE, vehicle) { const interior = vehicle.getObjectByName("Interior"); if (!interior?.isMesh || !interior.geometry?.attributes?.position) return null; interior.updateWorldMatrix(true, false); const geometry = interior.geometry.index ? interior.geometry.toNonIndexed() : interior.geometry; const positions = geometry.getAttribute("position"); const centroid = new THREE.Vector3(); const vertex = new THREE.Vector3(); const samples = []; for (let offset = 0; offset < positions.count; offset += 3) { centroid.set(0, 0, 0); for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) { vertex.fromBufferAttribute(positions, offset + vertexOffset); vertex.applyMatrix4(interior.matrixWorld); centroid.add(vertex); } centroid.multiplyScalar(1 / 3); const insideDriverCushion = ( centroid.x >= 0.18 && centroid.x <= 1.18 && centroid.y >= 0.25 && centroid.y <= 1.15 && centroid.z >= -0.12 && centroid.z <= 0.98 ); if (insideDriverCushion) samples.push(centroid.y); } if (geometry !== interior.geometry) geometry.dispose(); if (samples.length < 20) return null; samples.sort((left, right) => left - right); const quantile = (ratio) => samples[Math.min( samples.length - 1, Math.floor((samples.length - 1) * ratio), )]; return { triangleCount: samples.length, medianY: quantile(0.5), upperQuartileY: quantile(0.75), surfaceY: quantile(0.9), }; } function carveReferencePassengerSeat(THREE, vehicle) { const interior = vehicle.getObjectByName("Interior"); if (!interior?.isMesh || !interior.geometry?.attributes?.position) return 0; interior.updateWorldMatrix(true, false); const source = interior.geometry.index ? interior.geometry.toNonIndexed() : interior.geometry.clone(); const positions = source.getAttribute("position"); const centroid = new THREE.Vector3(); const vertex = new THREE.Vector3(); const keptVertices = []; let removedTriangles = 0; for (let offset = 0; offset < positions.count; offset += 3) { centroid.set(0, 0, 0); for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) { vertex.fromBufferAttribute(positions, offset + vertexOffset); vertex.applyMatrix4(interior.matrixWorld); centroid.add(vertex); } centroid.multiplyScalar(1 / 3); const insidePassengerSeat = ( centroid.x >= -1.15 && centroid.x <= -0.12 && centroid.y >= 0.05 && centroid.y <= 2.05 && centroid.z >= -0.92 && centroid.z <= 1.08 ); if (insidePassengerSeat) { removedTriangles += 1; continue; } keptVertices.push(offset, offset + 1, offset + 2); } if (removedTriangles < 40) { source.dispose(); return 0; } const carved = new THREE.BufferGeometry(); Object.entries(source.attributes).forEach(([name, attribute]) => { const values = new attribute.array.constructor(keptVertices.length * attribute.itemSize); keptVertices.forEach((sourceIndex, targetIndex) => { for (let component = 0; component < attribute.itemSize; component += 1) { values[targetIndex * attribute.itemSize + component] = ( attribute.array[sourceIndex * attribute.itemSize + component] ); } }); carved.setAttribute( name, new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized), ); }); carved.computeBoundingBox(); carved.computeBoundingSphere(); interior.geometry = carved; source.dispose(); return removedTriangles; } function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) { return new Promise((resolve, reject) => { const FBXLoaderClass = window.FBXLoader || THREE.FBXLoader; if (!FBXLoaderClass) { reject(new Error("FBXLoader is unavailable")); return; } const manager = new THREE.LoadingManager(); manager.setURLModifier((url) => { const cleanUrl = String(url).replaceAll("\\", "/"); const fileName = decodeURIComponent(cleanUrl.split("/").pop() || ""); if (!/\.(png|jpe?g)$/i.test(fileName)) return url; return `${REFERENCE_VEHICLE_ROOT}${encodeURIComponent(fileName)}`; }); const loader = new FBXLoaderClass(manager); loader.setResourcePath(REFERENCE_VEHICLE_ROOT); loader.load( REFERENCE_VEHICLE_URL, (vehicle) => { const normalized = normalizeReferenceVehicle(THREE, vehicle); const driverCushion = measureReferenceDriverCushion(THREE, vehicle); const removedPassengerTriangles = carveReferencePassengerSeat(THREE, vehicle); const report = configureReferenceVehicleModel(THREE, vehicle); vehicle.name = "REFERENCE_VEHICLE_OUTLINE"; vehicle.userData.twinRole = "reference_vehicle"; scene.add(vehicle); vehicleSystems.fallbackExteriorObjects.forEach((object) => { object.visible = false; }); vehicleSystems.fallbackInteriorObjects.forEach((object) => { object.visible = false; }); console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({ meshes: report.meshes, size: normalized.size.toArray().map((value) => Number(value.toFixed(3))), removedPassengerTriangles, driverCushion, })); resolve({ vehicle, report, normalized, driverCushion }); }, (event) => { if (!fallback || !event.total) return; const progress = Math.min(99, Math.round(event.loaded / event.total * 100)); const label = fallback.querySelector("span"); if (label) label.textContent = `正在加载整车参考模型 · ${progress}%`; }, reject, ); }); } function initSeatTwin() { const canvas = $("#seatTwinCanvas"); const stage = $("#seatTwin"); const fallback = $("#twinFallback"); if (!canvas || !stage) return; if (!window.THREE) { fallback.classList.add("error"); fallback.innerHTML = '3D 引擎加载失败,精确状态仍可用'; refreshIcons(); return; } const THREE = window.THREE; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" }); renderer.autoClear = false; renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace; else renderer.outputEncoding = THREE.sRGBEncoding; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe8eeec); scene.fog = new THREE.Fog(0xe8eeec, 12, 19); const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40); const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2); hemisphereLight.layers.enable(1); scene.add(hemisphereLight); const keyLight = new THREE.DirectionalLight(0xffffff, 3.2); keyLight.layers.enable(1); keyLight.position.set(-4, 7, 6); keyLight.castShadow = true; keyLight.shadow.mapSize.set(1024, 1024); keyLight.shadow.camera.left = -6; keyLight.shadow.camera.right = 6; keyLight.shadow.camera.top = 6; keyLight.shadow.camera.bottom = -4; scene.add(keyLight); const fillLight = new THREE.DirectionalLight(0x9fc7d7, 1.15); fillLight.layers.enable(1); fillLight.position.set(5, 3, -5); scene.add(fillLight); const floor = new THREE.Mesh( new THREE.PlaneGeometry(12, 9), new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }), ); floor.rotation.x = -Math.PI / 2; floor.position.y = -0.16; floor.receiveShadow = true; scene.add(floor); const grid = new THREE.GridHelper(10, 20, 0xaebdb9, 0xcbd6d2); grid.position.y = -0.155; grid.material.transparent = true; grid.material.opacity = 0.16; scene.add(grid); const vehicleSystems = createTwinCabin(THREE, scene); applyTwinOutlineStyle(THREE, vehicleSystems.root, { color: 0x4f747c, lineOpacity: 0.46, fillOpacity: 0.012, renderOrder: 40, thresholdAngle: 30, }); configureTwinSolidEntity(THREE, vehicleSystems.ventMeshes, { renderOrder: 52, }); configureTwinSolidEntity(THREE, vehicleSystems.ambientMeshes, { renderOrder: 56, }); configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes, { transparent: false, opacity: 1, depthWrite: true, renderOrder: 54, }); configureTwinSolidEntity(THREE, vehicleSystems.roofShade, { transparent: true, opacity: 0.08, renderOrder: 49, }); const models = { passenger: createTwinSeat(THREE, -1.05, 0x4a78af), }; configureActivePassengerSeatStyle(models.passenger); models.passenger.root.scale.setScalar(0.5); 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, }); scene.add(models.passenger.root); const fixedModels = { driver: configureFixedTwinSeat( THREE, createTwinSeat(THREE, 0.82, 0x667f84), [0.82, 0.18, -0.25], "fixed_front_driver", ), rear: createTwinRearBench(THREE), }; applyTwinOutlineStyle(THREE, fixedModels.driver.root, { color: 0x5e7479, lineOpacity: 0.46, fillOpacity: 0.01, renderOrder: 41, thresholdAngle: 26, }); applyTwinOutlineStyle(THREE, fixedModels.rear, { color: 0x5e7479, lineOpacity: 0.42, fillOpacity: 0.01, renderOrder: 41, thresholdAngle: 26, }); scene.add(fixedModels.driver.root); vehicleSystems.root.add(fixedModels.rear); [ vehicleSystems.root, models.passenger.root, fixedModels.driver.root, fixedModels.rear, ].forEach((root) => { root.traverse((object) => object.layers.set(1)); }); const initialDevice = () => ({ ventilation: { state: "off", level: 0 }, heating: { state: "off", level: 0 }, position: Object.fromEntries(Object.entries(TWIN_DEFAULT_POSITIONS).map(([axis, position_pct]) => [axis, { position_pct, fault: false }])), }); state.seatTwin = { renderer, scene, camera, models, fixedModels, vehicleSystems, selected: "passenger", actuatorState: { passenger: initialDevice() }, explicitAngleTargets: { passenger: {} }, view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 }, dragging: false, pointer: { x: 0, y: 0 }, }; loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) .then(({ vehicle, report, normalized, driverCushion }) => { if (!state.seatTwin) return; state.seatTwin.referenceVehicle = vehicle; state.seatTwin.referenceReport = report; state.seatTwin.referenceBounds = normalized.bounds; fixedModels.driver.root.visible = false; fixedModels.rear.visible = false; if (driverCushion) { const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot); const verticalCorrection = THREE.MathUtils.clamp( driverCushion.surfaceY - cushionBoundsBefore.max.y, -0.08, 0.42, ); models.passenger.root.position.y += verticalCorrection; models.passenger.root.updateMatrixWorld(true); const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot); models.passenger.root.userData.cushionSurfaceY = cushionBoundsAfter.max.y; canvas.dataset.activeSeatCushionSurface = cushionBoundsAfter.max.y.toFixed(3); canvas.dataset.activeSeatRootY = models.passenger.root.position.y.toFixed(3); canvas.dataset.referenceDriverCushionSurface = driverCushion.surfaceY.toFixed(3); canvas.dataset.referenceDriverCushionSamples = String(driverCushion.triangleCount); canvas.dataset.seatCushionAligned = String( Math.abs(cushionBoundsAfter.max.y - driverCushion.surfaceY) < 0.025 ); } models.passenger.root.updateMatrixWorld(true); const activeSeatBounds = new THREE.Box3().setFromObject(models.passenger.root); const activeSeatSize = activeSeatBounds.getSize(new THREE.Vector3()); const activeSeatContained = ( normalized.bounds.containsPoint(activeSeatBounds.min) && normalized.bounds.containsPoint(activeSeatBounds.max) ); canvas.dataset.activeSeatContained = String(activeSeatContained); canvas.dataset.activeSeatSize = activeSeatSize .toArray() .map((value) => value.toFixed(2)) .join("x"); canvas.dataset.referenceModelLoaded = "true"; fallback.hidden = true; }) .catch((error) => { console.error("[vehicle-twin] failed to load reference vehicle model", error); fallback.classList.add("error"); fallback.innerHTML = '实车模型加载失败,已切换轻量回退模型'; refreshIcons(); window.setTimeout(() => { fallback.hidden = true; }, 3200); }); function resetView() { Object.assign(state.seatTwin.view, { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0, }); } function resize() { const width = Math.max(1, canvas.clientWidth); const height = Math.max(1, canvas.clientHeight); renderer.setSize(width, height, false); camera.aspect = width / height; camera.updateProjectionMatrix(); state.seatTwin.narrowViewport = width < 600; } new ResizeObserver(resize).observe(stage); resize(); stage.addEventListener("pointerdown", (event) => { state.seatTwin.dragging = true; state.seatTwin.pointer = { x: event.clientX, y: event.clientY }; stage.setPointerCapture(event.pointerId); stage.classList.add("dragging"); }); stage.addEventListener("pointermove", (event) => { const twin = state.seatTwin; if (!twin?.dragging) return; const dx = event.clientX - twin.pointer.x; const dy = event.clientY - twin.pointer.y; twin.pointer = { x: event.clientX, y: event.clientY }; twin.view.yaw -= dx * 0.006; twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004)); }); const endDrag = () => { if (!state.seatTwin) return; state.seatTwin.dragging = false; stage.classList.remove("dragging"); }; stage.addEventListener("pointerup", endDrag); stage.addEventListener("pointercancel", endDrag); stage.addEventListener("wheel", (event) => { event.preventDefault(); const twin = state.seatTwin; twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006)); }, { passive: false }); $("#twinReset").addEventListener("click", resetView); $$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat))); const degrees = THREE.MathUtils.degToRad; const projectedPosition = new THREE.Vector3(); function positionTwinCallouts() { const twin = state.seatTwin; const model = twin.models[twin.selected]; const width = canvas.clientWidth; const height = canvas.clientHeight; scene.updateMatrixWorld(true); camera.updateMatrixWorld(); Object.entries(model.anchors).forEach(([axis, anchor]) => { anchor.getWorldPosition(projectedPosition); projectedPosition.project(camera); const callout = $(`[data-callout="${axis}"]`); const includedAxis = { backrest_included_angle: "backrest_recline", legrest_included_angle: "legrest_height", }[axis]; const requested = ( !includedAxis || twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null ); const visible = ( requested && projectedPosition.z > -1 && projectedPosition.z < 1 ); callout.hidden = !visible; if (!visible) return; const anchorX = (projectedPosition.x * 0.5 + 0.5) * width; const anchorY = (-projectedPosition.y * 0.5 + 0.5) * height; const offset = { longitudinal: { x: -104, y: 50 }, backrest_recline: { x: -108, y: -28 }, cushion_tilt: { x: 106, y: -4 }, legrest_height: { x: 112, y: 52 }, backrest_included_angle: { x: -112, y: 42 }, legrest_included_angle: { x: 114, y: 78 }, }[axis] || { x: 0, y: 0 }; const halfWidth = Math.max(46, callout.offsetWidth / 2); const halfHeight = Math.max(22, callout.offsetHeight / 2); const x = Math.max(halfWidth + 10, Math.min(width - halfWidth - 10, anchorX + offset.x)); const y = Math.max(halfHeight + 10, Math.min(height - halfHeight - 10, anchorY + offset.y)); callout.style.left = `${x}px`; callout.style.top = `${y}px`; const dx = anchorX - x; const dy = anchorY - y; const distance = Math.max(1, Math.hypot(dx, dy)); const unitX = dx / distance; const unitY = dy / distance; const horizontalBoundary = Math.abs(unitX) > 0.001 ? halfWidth / Math.abs(unitX) : Number.POSITIVE_INFINITY; const verticalBoundary = Math.abs(unitY) > 0.001 ? halfHeight / Math.abs(unitY) : Number.POSITIVE_INFINITY; const startDistance = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary) + 3); callout.style.setProperty("--leader-start-x", `${unitX * startDistance}px`); callout.style.setProperty("--leader-start-y", `${unitY * startDistance}px`); callout.style.setProperty("--leader-length", `${Math.max(0, distance - startDistance - 2)}px`); callout.style.setProperty("--leader-angle", `${Math.atan2(dy, dx)}rad`); callout.style.setProperty("--leader-dx", `${dx}px`); callout.style.setProperty("--leader-dy", `${dy}px`); }); } function animate(timestamp = 0) { const twin = state.seatTwin; if (!twin) return; Object.values(twin.models).forEach((model) => { Object.keys(model.positions).forEach((axis) => { const delta = model.targets[axis] - model.positions[axis]; model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085; }); Object.keys(model.angles).forEach((axis) => { const delta = model.angleTargets[axis] - model.angles[axis]; model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085; }); model.slide.position.z = (model.positions.longitudinal - 50) * 0.012; const cushionAngle = model.positions.cushion_tilt * 0.09; model.cushionPivot.rotation.x = degrees(-cushionAngle); model.backrestPivot.rotation.x = ( model.cushionPivot.rotation.x - degrees(model.angles.backrest_recline - 90) ); model.legrestPivot.rotation.x = degrees( 180 - cushionAngle - model.angles.legrest_height, ); model.legrestExtension.position.z = model.positions.legrest_extension * 0.0022; updateTwinAngleGuide( model.angleGuides.backrest_recline, model.outerSide * 0.83, 0.62, -0.5, cushionAngle, model.angles.backrest_recline, 0.58, ); updateTwinAngleGuide( model.angleGuides.legrest_height, -model.outerSide * 0.7, 0.64, 0.7, cushionAngle + 180, model.angles.legrest_height, 0.48, ); updateTwinThermalVisual(model, timestamp); }); updateTwinVehicleVisual(twin.vehicleSystems, timestamp); const { view } = twin; const cameraDistance = view.distance * (twin.narrowViewport ? 1.08 : 1); const horizontal = Math.cos(view.pitch) * cameraDistance; const cameraTargetX = view.targetX; camera.position.set( cameraTargetX + Math.sin(view.yaw) * horizontal, 0.82 + Math.sin(view.pitch) * cameraDistance, Math.cos(view.yaw) * horizontal, ); camera.lookAt(cameraTargetX, 1.14, -0.08); positionTwinCallouts(); renderer.clear(); camera.layers.set(0); renderer.render(scene, camera); renderer.clearDepth(); const sceneBackground = scene.background; scene.background = null; camera.layers.set(1); renderer.render(scene, camera); scene.background = sceneBackground; camera.layers.set(0); if (!canvas.dataset.rendered) canvas.dataset.rendered = "true"; window.requestAnimationFrame(animate); } setTwinSelection("passenger"); animate(); } function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.classList.add("show"); window.clearTimeout(showToast.timer); showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200); } function selectProvider(provider) { state.provider = provider; $$(".segment[data-provider]").forEach((button) => { button.classList.toggle("active", button.dataset.provider === provider); }); } function updateSeatFeedbackModeLabel() { const label = $("#seatFeedbackModeLabel"); if (!label) return; if (state.seatFeedbackSource === "physical_can") { label.textContent = "PHYSICAL CAN · BENCH SYNCHRONIZED"; return; } if (state.seatFeedbackSource === "physical_partial") { label.textContent = "PHYSICAL CAN · PARTIAL FEEDBACK"; return; } if (state.seatFeedbackSource === "unavailable") { label.textContent = "PHYSICAL CAN · SYNC UNAVAILABLE"; return; } if (state.seatFeedbackSource === "simulated_hardware") { label.textContent = "SIMULATED HIL · NO PHYSICAL CAN"; return; } label.textContent = state.executionMode === "hardware" ? "PHYSICAL CAN FEEDBACK" : "SIMULATED FEEDBACK"; } function selectExecutionMode(mode) { if (mode === "hardware" && !state.runtime?.hardware?.configured) return; if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return; state.executionMode = mode; $$('[data-execution-mode]').forEach((button) => { button.classList.toggle("active", button.dataset.executionMode === mode); }); const hardware = mode === "hardware"; const simulatedHardware = mode === "simulated_hardware"; const approvalRequired = Boolean(state.runtime?.hardware?.approval_required); $("#approvalField").hidden = !hardware || !approvalRequired; $("#simulationFaultField").hidden = !simulatedHardware; $("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim(); $("#executionBadge").innerHTML = hardware ? 'HIL · 真实 CAN' : simulatedHardware ? 'SIM · 模拟台架' : 'DRY-RUN'; $("#safetyTitle").textContent = hardware ? "真实台架安全门禁" : simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界"; $("#safetyText").textContent = hardware ? "副驾座椅映射到统一实体执行器;绝对位置会实时检查板端标定,小幅相对移动可走受限定时/Hall 回读,异常会尝试 STOP_ALL。" : simulatedHardware ? "故障只注入执行报告;物理 CAN、ZLG 适配器和 IAR 固件路径保持关闭。" : "四域 CAN 均为 dry-run,真实发送被强制关闭。"; $("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行"; $("#feedbackModeLabel").textContent = hardware ? "T2G BENCH · PHYSICAL CAN FEEDBACK" : simulatedHardware ? "SIMULATED HIL · FAULT INJECTION" : "MATRIX-BACKED · SIMULATED FEEDBACK"; const runButtonLabel = $("#runButton span"); if (runButtonLabel) { runButtonLabel.textContent = hardware ? (approvalRequired ? "授权并真实执行" : "真实执行") : simulatedHardware ? "运行故障仿真" : "生成并执行"; } updateSeatFeedbackModeLabel(); refreshIcons(); } function vehicleProfiles() { return Array.isArray(state.runtime?.vehicles) ? state.runtime.vehicles : []; } function findVehicleProfile(vehicleId) { return vehicleProfiles().find((vehicle) => vehicle.id === vehicleId) || state.runtime?.vehicle || null; } function applyTwinAngleRanges(vehicle) { Object.keys(TWIN_ANGLE_RANGES).forEach((axis) => delete TWIN_ANGLE_RANGES[axis]); const motors = vehicle?.seat?.motors || {}; Object.entries(motors).forEach(([axis, motor]) => { const display = motor?.display_angle; if (!display) return; const zero = Number(display.zero_deg); const maximum = Number(display.max_deg); if (Number.isFinite(zero) && Number.isFinite(maximum) && maximum > zero) { TWIN_ANGLE_RANGES[axis] = [zero, maximum]; } }); } function applySeatMotorAvailability(vehicle) { const motors = vehicle?.seat?.motors || {}; const availableAxes = new Set(Object.keys(motors)); state.availableSeatAxes = availableAxes; Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => { const available = availableAxes.has(axis); $$(`[data-axis="${axis}"], [data-twin-axis="${axis}"], [data-callout="${axis}"]`) .forEach((item) => { item.classList.toggle("seat-axis-unavailable", !available); item.setAttribute("aria-hidden", String(!available)); }); const motorName = motors[axis]?.name || axis; const twinLabel = axis === "longitudinal" ? `${motorName}位置` : ["cushion_tilt", "backrest_recline", "legrest_height"].includes(axis) ? `${motorName}夹角` : motorName; $$(`[data-twin-axis="${axis}"] > span, [data-callout="${axis}"] > span`) .forEach((label) => { label.textContent = twinLabel; }); }); const cushionName = motors.cushion_tilt?.name; const backrestName = motors.backrest_recline?.name; const legrestName = motors.legrest_height?.name; const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span'); const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span'); if (backrestIncludedLabel && backrestName && cushionName) { backrestIncludedLabel.textContent = `${backrestName}-${cushionName}夹角`; } if (legrestIncludedLabel && cushionName && legrestName) { legrestIncludedLabel.textContent = `${cushionName}-${legrestName}夹角`; } } function renderSeatMotorRows(vehicle) { const motors = Object.entries(vehicle?.seat?.motors || {}); $$(".seat-card .motion-state-grid").forEach((grid) => { const fragment = document.createDocumentFragment(); motors.forEach(([axis, motor]) => { const item = document.createElement("div"); item.className = "motion-axis"; item.dataset.axis = axis; const label = document.createElement("span"); label.textContent = motor?.name || axis; const value = document.createElement("b"); value.textContent = "--"; item.append(label, value, document.createElement("i")); fragment.append(item); }); grid.replaceChildren(fragment); }); } function selectVehicle(vehicleId, { silent = false } = {}) { const vehicle = findVehicleProfile(vehicleId); if (!vehicle) return; state.vehicleId = vehicle.id; const select = $("#vehicleSelect"); if (select) select.value = vehicle.id; applyTwinAngleRanges(vehicle); renderSeatMotorRows(vehicle); applySeatMotorAvailability(vehicle); const longitudinal = vehicle.seat?.motors?.longitudinal; const backrest = vehicle.seat?.motors?.backrest_recline; $("#vehicleNote").textContent = longitudinal && backrest ? `${vehicle.name} · 水平 ${longitudinal.range}${longitudinal.unit} · 靠背 ${backrest.range}${backrest.unit}` : `${vehicle.name} · 已加载车型配置`; if (state.result?.actuator_state) { renderSeatState(state.result.actuator_state); } else if (state.seatTwin?.actuatorState) { renderSeatState(state.seatTwin.actuatorState); } if (!silent) showToast(`已切换车型:${vehicle.name}`); } function populateVehicleSelector() { const select = $("#vehicleSelect"); if (!select) return; const vehicles = vehicleProfiles(); select.innerHTML = vehicles.length ? vehicles.map((vehicle) => ``).join("") : ''; const defaultVehicleId = state.runtime?.default_vehicle_id || state.runtime?.vehicle?.id || vehicles[0]?.id || ""; select.disabled = vehicles.length === 0; if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true }); } function modeProfiles() { return Array.isArray(state.runtime?.modes) ? state.runtime.modes : []; } function modeChipLabel(mode) { const name = String(mode?.name || mode?.id || "模式"); return name.endsWith("模式") && name.length > 2 ? name.slice(0, -2) : name; } function populateModeChips() { const group = $(".mode-chips"); if (!group) return; const buttons = () => $$("button", group); modeProfiles().forEach((mode) => { const label = modeChipLabel(mode); let button = buttons().find((item) => item.dataset.modeProfileId === mode.id) || buttons().find((item) => item.textContent.trim() === label); if (!button) { button = document.createElement("button"); button.type = "button"; const icon = document.createElement("i"); icon.dataset.lucide = mode.icon || "bookmark"; button.append(icon, document.createTextNode(label)); group.append(button); } button.dataset.modeProfileId = mode.id; button.dataset.modeCommand = `进入${mode.name}`; button.title = mode.description || mode.name; }); refreshIcons(); } function applyModeCommand(command) { if (!command) return; $("#utterance").value = command; $("#requestForm").requestSubmit(); } function toggleSaveModeComposer() { const composer = $("#saveModeComposer"); composer.hidden = !composer.hidden; if (!composer.hidden) $("#saveModeName").focus(); } function saveCurrentModeProfile() { const input = $("#saveModeName"); const rawName = input.value.trim(); if (!rawName) { showToast("请输入模式名称"); return; } const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`; $("#utterance").value = `这个模式不错,固化为${modeName}`; $("#saveModeComposer").hidden = true; input.value = ""; $("#requestForm").requestSubmit(); } async function loadRuntime() { try { const response = await apiRequest("/api/runtime", { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); state.runtime = await response.json(); populateVehicleSelector(); populateModeChips(); const hardware = state.runtime.hardware || {}; const badge = $("#modelBadge"); const gptButton = $('[data-provider="openai"]'); if (state.runtime.openai_available) { badge.classList.remove("danger"); badge.innerHTML = `${state.runtime.openai_model}`; const responsesEndpoint = state.runtime.openai_responses_endpoint || "Responses API"; $("#providerNote").textContent = `${state.runtime.openai_model} · ${responsesEndpoint} · 失败时自动降级本地规则`; selectProvider("openai"); } else { badge.classList.add("danger"); badge.innerHTML = 'GPT 未配置'; gptButton.disabled = true; gptButton.title = "设置 OPENAI_API_KEY 后启用"; $("#providerNote").textContent = "未设置 OPENAI_API_KEY,已切换为本地规则解析"; selectProvider("local"); } $("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`; const hardwareButton = $("#hardwareMode"); hardwareButton.disabled = !hardware.configured; const simulation = state.runtime.simulated_hardware || {}; $("#simulationMode").disabled = !simulation.enabled; $("#simulationMode").title = simulation.enabled ? "开发专用模拟台架,不连接物理 CAN" : "通过安卓联调脚本或环境变量启用模拟台架"; const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile"; const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用"; const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌"; $("#hardwareNote").textContent = hardware.configured ? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}` : `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`; $("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)"; $("#autoFlashNote").textContent = hardware.auto_flash_enabled ? "未检测到匹配固件时,将在发送座椅控制帧前通过 IAR C-SPY 自动烧录并重新验证。" : "仅接受周期反馈与四轴量程签名匹配的统一固件;需要自动烧录时请启用服务端开关。"; $("#hardwareBadge").className = `status-badge ${hardware.configured ? "" : "danger"}`.trim(); $("#hardwareBadge").innerHTML = hardware.configured ? '硬件已配置' : '硬件锁定'; $("#hardwareCoverage").textContent = hardware.configured ? `可选 · ${approvalText}` : "已阻断"; $("#hardwareCoverage").className = hardware.configured ? "available" : "blocked"; const modeAvailable = state.executionMode === "hardware" ? hardware.configured : state.executionMode === "simulated_hardware" ? simulation.enabled : true; selectExecutionMode(modeAvailable ? state.executionMode : "dry_run"); refreshIcons(); } catch (error) { $("#modelBadge").textContent = "运行时离线"; $("#modelBadge").classList.add("danger"); $("#providerNote").textContent = "无法读取后端运行状态"; showToast(`运行时连接失败:${error.message}`); } } async function synchronizePhysicalSeatState() { if (!state.runtime?.hardware?.configured) return false; const query = new URLSearchParams({ conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "", }); try { const response = await apiRequest(`/api/hardware/seat-state?${query}`, { cache: "no-store", }); if (response.status === 401) { window.location.replace("/login"); return false; } if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); renderSeatState(payload.actuator_state); state.seatFeedbackSource = payload.complete ? "physical_can" : "physical_partial"; updateSeatFeedbackModeLabel(); return true; } catch (_error) { if (!state.seatFeedbackSource.startsWith("physical")) { state.seatFeedbackSource = "unavailable"; updateSeatFeedbackModeLabel(); } return false; } } async function loadAuthStatus() { const response = await apiRequest("/api/auth/status", { cache: "no-store" }); if (!response.ok) return; const auth = await response.json(); if (auth.enabled && !auth.authenticated) { window.location.replace("/login"); return; } $("#logoutButton").hidden = !auth.enabled; } async function logout() { await apiRequest("/api/auth/logout", { method: "POST" }); window.location.replace("/login"); } function setRunStatus(status, text) { const element = $("#runStatus"); element.className = `run-status ${status}`; element.textContent = text; } function resetPipeline() { $$(".pipeline-step").forEach((step) => step.classList.remove("active", "succeeded", "failed")); } function startProgress() { resetPipeline(); const steps = $$(".pipeline-step"); let index = 0; steps[0].classList.add("active"); state.progressTimer = window.setInterval(() => { steps[index]?.classList.remove("active"); steps[index]?.classList.add("succeeded"); index = Math.min(index + 1, steps.length - 1); steps[index]?.classList.add("active"); }, 360); } function renderPipeline(events) { window.clearInterval(state.progressTimer); resetPipeline(); const byStage = new Map(events.map((event) => [event.stage, event])); const schemaEvent = byStage.get("schema_validate"); const domainEvent = byStage.get("domain_validate"); $$(".pipeline-step").forEach((step) => { let event = byStage.get(step.dataset.stage); if (step.dataset.stage === "domain_validate") { event = schemaEvent?.status === "failed" ? schemaEvent : domainEvent; } if (!event) return; step.classList.add(event.status === "failed" || event.status === "waiting_confirmation" ? "failed" : "succeeded"); }); } function yamlScalar(value) { if (value === null) return "null"; if (typeof value === "boolean" || typeof value === "number") return String(value); return JSON.stringify(String(value)); } function yamlLines(value, indent = 0) { const pad = " ".repeat(indent); if (Array.isArray(value)) { if (!value.length) return [`${pad}[]`]; return value.flatMap((item) => { if (item !== null && typeof item === "object") { const lines = yamlLines(item, indent + 2); return [`${pad}-${lines[0].slice(indent + 1)}`, ...lines.slice(1)]; } return [`${pad}- ${yamlScalar(item)}`]; }); } if (value !== null && typeof value === "object") { const entries = Object.entries(value); if (!entries.length) return [`${pad}{}`]; return entries.flatMap(([key, item]) => { if (item !== null && typeof item === "object") { return [`${pad}${key}:`, ...yamlLines(item, indent + 2)]; } return [`${pad}${key}: ${yamlScalar(item)}`]; }); } return [`${pad}${yamlScalar(value)}`]; } function toYaml(value) { return value ? `${yamlLines(value).join("\n")}\n` : "# 尚未生成\n"; } function renderDecision(result) { const list = $("#decisionList"); list.replaceChildren(); result.decision_summary.forEach((summary) => { const item = document.createElement("li"); item.textContent = summary; list.append(item); }); $("#providerUsed").textContent = result.provider_used || "--"; $("#tracePanelState").textContent = result.provider_used || result.provider_requested || "READY"; } function renderTrace(events) { const list = $("#traceList"); list.replaceChildren(); let duration = 0; events.forEach((event) => { duration += event.duration_ms || 0; const row = document.createElement("div"); row.className = `trace-event ${event.status}`; const code = document.createElement("code"); code.textContent = event.stage; const summary = document.createElement("span"); summary.textContent = event.summary; const time = document.createElement("small"); time.textContent = `${event.duration_ms || 0} ms`; row.append(code, summary, time); list.append(row); }); $("#traceDuration").textContent = `${duration} ms`; $("#traceNavMeta").textContent = `${events.length} 个阶段 · ${duration} ms`; } function renderValidation(issues) { const list = $("#validationList"); list.replaceChildren(); $("#validationCount").textContent = `${issues.length} 条`; if (!issues.length) { const row = document.createElement("div"); row.className = "validation-item"; row.innerHTML = "PASSall公共 Schema、领域 Schema 和语义约束均通过。"; list.append(row); return; } issues.forEach((issue) => { const row = document.createElement("div"); row.className = `validation-item ${issue.severity}`; const severity = document.createElement("b"); severity.textContent = issue.severity.toUpperCase(); const code = document.createElement("code"); code.textContent = `${issue.code} · ${issue.path}`; const message = document.createElement("span"); message.textContent = issue.message; row.append(severity, code, message); list.append(row); }); } function resetSeatState() { $$(".thermal-states > span").forEach((item) => { item.classList.remove("on"); $("b", item).textContent = "--"; }); $$(".motion-axis").forEach((item) => { $("b", item).textContent = "--"; $("i", item).style.width = "0%"; item.classList.remove("fault"); }); } function mergeSeatActuatorState(previousState, patchState) { const mergedState = { ...(previousState || {}) }; Object.entries(patchState || {}).forEach(([seat, functions]) => { const previousSeat = mergedState[seat] || {}; const mergedSeat = { ...previousSeat }; Object.entries(functions || {}).forEach(([feature, value]) => { if (feature === "position") { const previousPosition = previousSeat.position || {}; const mergedPosition = { ...previousPosition }; Object.entries(value || {}).forEach(([axis, motion]) => { mergedPosition[axis] = motion && typeof motion === "object" ? { ...(previousPosition[axis] || {}), ...motion } : motion; }); mergedSeat.position = mergedPosition; return; } mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value) ? { ...(previousSeat[feature] || {}), ...value } : value; }); mergedState[seat] = mergedSeat; }); return mergedState; } function renderSeatState(actuatorState) { if (!actuatorState) return; const mergedActuatorState = mergeSeatActuatorState( state.seatActuatorState, actuatorState, ); state.seatActuatorState = mergedActuatorState; resetSeatState(); Object.entries(mergedActuatorState).forEach(([seat, functions]) => { Object.entries(functions).forEach(([feature, value]) => { if (feature === "position") { Object.entries(value).forEach(([axis, motion]) => { const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`); if (!item) return; const position = Number(motion.position_pct || 0); const estimated = Boolean( motion.position_estimated || motion.position_valid === false ); $("b", item).textContent = `${estimated ? "≈" : ""}${position}%`; item.title = estimated ? "Hall 已确认相对运动;绝对位置尚未标定" : ""; $("i", item).style.width = `${Math.max(0, Math.min(100, position))}%`; item.classList.toggle("fault", Boolean(motion.fault)); }); return; } const item = $(`.seat-card[data-seat="${seat}"] [data-function="${feature}"]`); if (!item) return; item.classList.toggle("on", value.state === "on"); $("b", item).textContent = value.state === "on" ? `ON · L${value.level}` : "OFF"; }); }); updateSeatTwin(mergedActuatorState); } function renderDomainState(domainState) { if (!domainState) return; const climate = domainState.climate; if (climate) { const active = climate.power === "on" || climate.ac === "on" || Number(climate.fan_level) > 0; const driverTemperature = climate.zones?.driver?.temperature_c ?? climate.zones?.vehicle?.temperature_c ?? 24; const passengerTemperature = climate.zones?.passenger?.temperature_c ?? climate.zones?.vehicle?.temperature_c ?? 24; $('[data-domain-panel="climate"]').classList.toggle("active", active); $("#climatePower").textContent = climate.power === "on" ? "ON" : (active ? "ACTIVE" : "OFF"); $("#driverTemperature").innerHTML = `${driverTemperature}℃`; $("#passengerTemperature").innerHTML = `${passengerTemperature}℃`; $("#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" ? "内循环" : "外循环"; $("#acState").textContent = climate.ac === "on" ? "开启" : "关闭"; $("#defrostState").textContent = climate.front_defrost === "on" ? "开启" : "关闭"; const vehicleSystems = state.seatTwin?.vehicleSystems; if (vehicleSystems) { 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; if (roof) { const transmittance = Math.max(0, Math.min(100, Number(roof.transmittance_pct) || 0)); const shadePosition = Math.max(0, Math.min(100, Number(roof.shade_position_pct) || 0)); $('[data-domain-panel="roof"]').classList.toggle( "active", transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on", ); $("#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; if (ambient) { const active = ambient.power === "on"; const color = /^#[0-9A-Fa-f]{6}$/.test(ambient.color_hex || "") ? ambient.color_hex.toUpperCase() : "#74D9FF"; const brightness = Math.max(0, Math.min(100, Number(ambient.brightness_pct) || 0)); const opacity = active ? Math.max(0.18, brightness / 100) : 0.08; const modeLabels = { camping: "露营", rest: "小憩", cinema: "观影", meditation: "冥想", romantic: "浪漫", custom: "自定义", on: "开启", off: "关闭", }; $('[data-domain-panel="ambient_light"]').classList.toggle("active", active); $("#ambientPower").textContent = active ? "ON" : "OFF"; $$(".ambient-line").forEach((line) => { line.style.background = color; line.style.boxShadow = `0 0 ${Math.round(5 + brightness / 8)}px ${color}`; line.style.opacity = String(opacity); }); $("#ambientSwatch").style.background = color; $("#ambientColor").textContent = color; $("#ambientBrightness").textContent = `${brightness}%`; $("#ambientMode").textContent = modeLabels[ambient.mode] || ambient.mode || "自定义"; const vehicleSystems = state.seatTwin?.vehicleSystems; if (vehicleSystems) { vehicleSystems.targets.ambientPower = active ? 1 : 0; vehicleSystems.targets.ambientBrightness = brightness; vehicleSystems.colors.ambientTarget.set(color); } $('[data-twin-system="ambient"]').classList.toggle("active", active); $("#twinAmbientState").textContent = active ? `${color} · ${brightness}%` : "关闭"; } } function renderedConfig(result) { if (result.scene_config) { return { scene_config: result.scene_config, function_configs: result.function_configs, }; } return result.preset_config || result.function_config; } function renderedPlan(result) { const plans = result.command_plans || {}; return Object.keys(plans).length > 1 ? plans : result.command_plan; } function renderSavedPresets(result) { const indicator = $("#presetIndicator"); const presets = result.saved_presets || []; if (!presets.length) { indicator.hidden = true; return; } const latest = result.preset_config ? { name: result.preset_config.metadata.name, target: result.preset_config.metadata.labels.target, } : presets[presets.length - 1]; const targetName = latest.target === "driver" ? "主驾" : "副驾"; $("span", indicator).textContent = `已保存 ${targetName} · ${latest.name}`; indicator.hidden = false; } const CONFIG_DOMAIN_META = { seat: { label: "副驾座椅", icon: "armchair", className: "seat" }, climate: { label: "空调", icon: "fan", className: "climate" }, ambient_light: { label: "氛围灯", icon: "lamp-ceiling", className: "ambient" }, roof: { label: "智能天幕", icon: "panels-top-left", className: "roof" }, }; const CONFIG_CAPABILITY_LABELS = { "seat.set_ventilation": "设置座椅通风", "seat.set_heating": "设置座椅加热", "seat.set_longitudinal": "调节座椅前后位置", "seat.set_longitudinal_position": "调节座椅前后位置", "seat.set_cushion_tilt": "调节坐垫俯仰", "seat.set_backrest_recline": "调节靠背角度", "seat.set_legrest_height": "调节腿托角度", "seat.set_legrest_extension": "调节腿托伸缩", "climate.set_temperature": "设置空调温度", "climate.set_fan_level": "设置空调风量", "climate.set_power": "设置空调电源", "climate.set_ac": "设置 A/C", "climate.set_cycle_mode": "设置内外循环", "climate.set_outlet_mode": "设置出风模式", "climate.set_front_defrost": "设置前风挡除霜", "ambient_light.set_power": "设置氛围灯电源", "ambient_light.set_illumination": "设置氛围灯颜色与亮度", "ambient_light.set_color": "设置氛围灯颜色", "ambient_light.set_brightness": "设置氛围灯亮度", "ambient_light.set_mode": "设置氛围灯模式", "roof.set_transmittance": "设置天幕透光率", "roof.set_shade_position": "设置遮阳帘位置", "roof.set_automatic_dimming": "设置自动调光", }; const CONFIG_PARAMETER_LABELS = { power: "电源", state: "状态", level: "档位", temperature_c: "目标温度", fan_level: "目标风量", position_pct: "目标位置", angle_deg: "目标角度", color_hex: "目标颜色", brightness_pct: "目标亮度", transmittance_pct: "目标透光率", shade_position_pct: "遮阳帘位置", automatic_dimming: "自动调光", cycle_mode: "循环模式", outlet_mode: "出风模式", mode: "工作模式", }; function makeConfigElement(tagName, className, textValue) { const element = document.createElement(tagName); if (className) element.className = className; if (textValue != null) element.textContent = textValue; return element; } function configTargetLabel(target = {}, domain) { const scopes = { driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾", front_row: "前排", rear_row: "后排", vehicle: "全车", cabin: "全车舱", }; const rawScope = target.instance || target.zone || (domain === "seat" ? "passenger" : "vehicle"); const scope = scopes[rawScope] || rawScope; if (domain === "seat") return `${scope}座椅`; const device = CONFIG_DOMAIN_META[domain]?.label || target.device || domain; return `${scope} · ${device}`; } function formatConfigValue(name, value) { const stateLabels = { on: "开启", off: "关闭", internal: "内循环", external: "外循环", face: "面部出风", feet: "脚部出风", cinema: "观影", rest: "小憩", custom: "自定义", }; if (typeof value === "string" && stateLabels[value]) return stateLabels[value]; if (name === "temperature_c") return `${value} ℃`; if (name === "angle_deg") return `${value}°`; if (name === "fan_level" || name === "level") return `${value} 档`; if (name.endsWith("_pct")) return `${value}%`; if (typeof value === "boolean") return value ? "开启" : "关闭"; if (value && typeof value === "object") return JSON.stringify(value); return String(value ?? "--"); } function configEntries(result) { const configured = result.function_configs || {}; if (Object.keys(configured).length) return Object.entries(configured); const single = result.function_config || result.preset_config; const domain = single?.spec?.domain || "seat"; return single ? [[domain, single]] : []; } function planForDomain(result, domain) { if (result.command_plans?.[domain]) return result.command_plans[domain]; return result.command_plan?.spec?.domain === domain ? result.command_plan : null; } function renderConfigCards(result) { const container = $("#configCards"); container.replaceChildren(); const entries = configEntries(result); const operations = entries.flatMap(([domain, config]) => ( (config?.spec?.operations || []).map((operation) => ({ domain, config, operation })) )); if (!operations.length) { const empty = makeConfigElement("div", "config-empty"); empty.innerHTML = '暂无可展示的功能配置原始配置仍可在下方展开查看。'; container.append(empty); refreshIcons(); return 0; } const sceneSummary = makeConfigElement("div", "config-scene-summary"); const sceneCopy = makeConfigElement("div", "config-scene-copy"); const sceneIcon = makeConfigElement("span", "config-scene-icon"); sceneIcon.innerHTML = ''; const sceneText = makeConfigElement("div", ""); sceneText.append( makeConfigElement("strong", "", result.scene_config?.metadata?.name || "单域功能配置"), makeConfigElement("span", "", result.scene_config ? "SceneConfig 联合场景" : "FunctionConfig 独立控制"), ); sceneCopy.append(sceneIcon, sceneText); const sceneStats = makeConfigElement("div", "config-scene-stats"); [[entries.length, "控制域"], [operations.length, "控制动作"], [result.validation?.length || 0, "校验提示"]].forEach(([value, label]) => { const stat = makeConfigElement("span", ""); stat.append(makeConfigElement("b", "", value), makeConfigElement("small", "", label)); sceneStats.append(stat); }); sceneSummary.append(sceneCopy, sceneStats); container.append(sceneSummary); operations.forEach(({ domain, config, operation }, index) => { const meta = CONFIG_DOMAIN_META[domain] || { label: domain, icon: "box", className: "generic" }; const card = makeConfigElement("article", `config-card config-${meta.className}`); const header = makeConfigElement("header", "config-card-header"); const identity = makeConfigElement("div", "config-card-identity"); const icon = makeConfigElement("span", "config-card-icon"); icon.innerHTML = ``; const title = makeConfigElement("div", ""); title.append( makeConfigElement("small", "", `ACTION ${String(index + 1).padStart(2, "0")} · ${domain.toUpperCase()}`), makeConfigElement("strong", "", CONFIG_CAPABILITY_LABELS[operation.capability] || operation.capability || meta.label), ); identity.append(icon, title); header.append(identity, makeConfigElement("span", "config-domain-badge", meta.label)); const objectBlock = makeConfigElement("section", "config-object-block"); objectBlock.append( makeConfigElement("span", "config-block-label", "控制对象"), makeConfigElement("strong", "config-object-name", configTargetLabel(operation.target, domain)), makeConfigElement("code", "config-object-path", `${operation.target?.device || domain} / ${operation.target?.zone || operation.target?.instance || "vehicle"}`), ); const parametersBlock = makeConfigElement("section", "config-parameters-block"); parametersBlock.append(makeConfigElement("span", "config-block-label", "目标参数")); const parameterGrid = makeConfigElement("div", "config-parameter-grid"); const parameters = Object.entries(operation.parameters || {}); (parameters.length ? parameters : [["state", "--"]]).forEach(([name, value]) => { const item = makeConfigElement("div", "config-parameter"); const valueNode = makeConfigElement("strong", "", formatConfigValue(name, value)); if (name === "color_hex" && /^#[0-9A-Fa-f]{6}$/.test(String(value))) { const swatch = makeConfigElement("i", "config-color-swatch"); swatch.style.background = value; valueNode.prepend(swatch); } item.append( makeConfigElement("span", "", CONFIG_PARAMETER_LABELS[name] || name), valueNode, ); parameterGrid.append(item); }); parametersBlock.append(parameterGrid); const plan = planForDomain(result, domain); const allCommands = plan?.spec?.commands || []; const matchedCommands = allCommands.filter((command) => !command.operation_ref || command.operation_ref === operation.id); const command = matchedCommands[0] || null; const hardwareBlock = makeConfigElement("section", `config-hardware-block ${command ? "mapped" : "unmapped"}`); hardwareBlock.append(makeConfigElement("span", "config-block-label", "硬件映射")); const route = makeConfigElement("div", "config-hardware-route"); const from = makeConfigElement("div", "config-route-node"); from.append(makeConfigElement("small", "", "逻辑配置"), makeConfigElement("strong", "", operation.id || "operation")); const arrow = makeConfigElement("i", "config-route-arrow"); arrow.innerHTML = ''; const to = makeConfigElement("div", "config-route-node target"); to.append( makeConfigElement("small", "", command?.bus || plan?.spec?.platform || "待编译"), makeConfigElement("strong", "", command?.message || "暂无 CAN 命令"), ); route.append(from, arrow, to); hardwareBlock.append(route); const signals = Object.keys(command?.signals || {}); const signalList = makeConfigElement("div", "config-signal-list"); if (signals.length) { signals.slice(0, 5).forEach((signal) => signalList.append(makeConfigElement("code", "", signal))); if (signals.length > 5) signalList.append(makeConfigElement("code", "config-signal-more", `+${signals.length - 5}`)); } else { signalList.append(makeConfigElement("span", "config-unmapped-note", "虚拟状态可预览,等待硬件编译映射")); } hardwareBlock.append(signalList); const footer = makeConfigElement("footer", "config-card-footer"); footer.append( makeConfigElement("span", "", `超时 ${operation.timeout_ms ?? "--"} ms`), makeConfigElement("span", "", operation.failure_policy === "continue" ? "失败后继续" : "失败即中止"), makeConfigElement("span", "", config.metadata?.labels?.execution_scope === "virtual_only" ? "仅虚拟执行" : "已通过配置校验"), ); card.append(header, objectBlock, parametersBlock, hardwareBlock, footer); container.append(card); }); refreshIcons(); return operations.length; } function renderCode(result) { const config = renderedConfig(result); $("#configCode code").textContent = toYaml(config); const configCardCount = renderConfigCards(result); const configDomains = Object.keys(result.function_configs || {}); $("#configFileName").textContent = result.scene_config ? `scene.yaml + functions/{${configDomains.join(",")}}.yaml` : result.preset_config ? "presets/seat.yaml" : `functions/${configDomains[0] || "seat"}.yaml`; $("#configNavMeta").textContent = `${configCardCount} 个控制动作`; $("#planCode code").textContent = toYaml(renderedPlan(result)); const planDomains = Object.keys(result.command_plans || {}); $("#planFileName").textContent = planDomains.length > 1 ? `command-plans/{${planDomains.join(",")}}.yaml` : `command-plans/${planDomains[0] || "seat"}.yaml`; const resultItems = result.execution_report?.spec?.results || []; const telemetry = resultItems.find((item) => item.telemetry?.feedback_frame)?.telemetry || resultItems[0]?.telemetry; const frames = resultItems .map((item) => item.telemetry?.command_frame) .filter(Boolean); const summary = $("#frameSummary"); if (frames.length) { const feedback = telemetry?.feedback_frame ? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}` : "NO DIRECT FEEDBACK"; const simulatedHil = result.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware"; const mode = simulatedHil ? "HIL · SIMULATED" : result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN"; summary.innerHTML = `Frames${frames.length}First payload${frames[0].frame_id} · ${frames[0].data_hex}Feedback${feedback}Mode${mode}`; $("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`; } else if (telemetry?.operation === "save_posture_preset") { summary.innerHTML = `OperationPRESET STORECANNO FRAME EMITTEDScopeCONVERSATION`; $("#planNavMeta").textContent = "预设存储 · 无 CAN 帧"; } else { summary.innerHTML = "Frame ID--Payload--ModeNOT EXECUTED"; $("#planNavMeta").textContent = "未生成 CAN 帧"; } } function renderExecution(result) { const summary = $("#executionSummary"); const list = $("#resultList"); list.replaceChildren(); const report = result.execution_report; if (!report) { summary.className = "execution-summary failed"; const compileIssue = result.validation?.find((issue) => issue.code === "CAN_COMPILE_REJECTED"); const blockedReason = { needs_confirmation: "请求需要补充确认", capability_unavailable: "目标能力已识别,但对应域尚未接入执行器", compile_failed: compileIssue?.message || "CAN 命令编译被安全约束阻止", }[result.status] || "配置或语义校验未通过"; summary.innerHTML = `
执行未开始${blockedReason}
`; $("#resultNavMeta").textContent = "未执行 · 查看原因"; $("#resultPanelState").textContent = "BLOCKED"; $("#resultPanelState").className = "workspace-panel-state failed"; refreshIcons(); return; } const reportSucceeded = report.spec.status === "succeeded"; summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`; const presetSave = Boolean(result.preset_config); const modeSave = Boolean(result.mode_profile); const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware"; const physical = report.spec.mode === "hil" && !simulatedHil; const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))]; const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length; const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified); const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware"); let executionDetail = modeSave ? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令` : presetSave ? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令" : simulatedHil ? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN` : physical ? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤` : `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`; if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`; if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件"; else if (firmwareResult) executionDetail += ";板上统一固件协议签名已校验"; if (result.status === "partially_succeeded") { executionDetail += ";未接入域请求已保留为 deferred request"; } if (!reportSucceeded) { const failure = report.spec.results.find((item) => item.status === "failed"); const failureTelemetry = failure?.telemetry || {}; executionDetail = simulatedHil ? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。` : failureTelemetry.error_type === "CanGatewayError" ? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}` : failureTelemetry.error_type === "FirmwareProgrammingError" ? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}` : failureTelemetry.calibration_required ? `大幅相对移动已转换为绝对目标,但 Motor${failureTelemetry.motor_selector} 的 Position_Valid=0;未发送绝对移动命令,请先在台架清空并备好急停后执行显式端点标定。` : failureTelemetry.failure_category === "stall_no_hall_activity" ? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},未检测到持续 Hall 活动;可能已到目标方向机械端点/存在阻挡,或 Hall 供电、信号线未接通。请先安全地反向移动 2° 诊断。` : failureTelemetry.failure_category === "stall_after_hall_activity" ? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},运动后发生堵转,请检查目标方向端点或机械阻挡。` : failureTelemetry.motor_result_name ? `Motor${failureTelemetry.motor_selector} ${failureTelemetry.motor_result_name},板端事件 ${failureTelemetry.last_motor_event_name || "UNAVAILABLE"};${failureTelemetry.stop_all_attempted ? "已尝试发送 STOP_ALL。" : "请检查硬件回读。"}` : failureTelemetry.stop_all_attempted ? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。" : failure?.message || "真实台架未返回满足目标的物理反馈。"; } const executionTitle = reportSucceeded ? modeSave ? "长期模式已保存" : presetSave ? "姿态预设已保存" : simulatedHil ? "模拟台架执行成功" : physical ? "真实台架执行成功" : "虚拟执行成功" : simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败"; summary.innerHTML = `
${executionTitle}${executionDetail}
`; $("#resultNavMeta").textContent = reportSucceeded ? `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}` : "执行失败 · 查看回读"; $("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : "FAILED"; $("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : "failed"}`; report.spec.results.forEach((item) => { const row = document.createElement("div"); row.className = "result-item"; row.innerHTML = ``; const text = document.createElement("div"); const title = document.createElement("strong"); title.textContent = item.ref; const detail = document.createElement("span"); detail.textContent = item.message; text.append(title, detail); const frame = document.createElement("code"); const firmwareTelemetry = item.telemetry?.firmware_verified ? [ item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED", item.telemetry.firmware_revision == null ? null : `REV ${item.telemetry.firmware_revision}`, item.telemetry.identity_frame_id && item.telemetry.identity_data_hex ? `${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}` : item.telemetry.protocol_signature, ].filter(Boolean).join(" · ") : null; frame.textContent = item.telemetry?.command_frame ? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}` : firmwareTelemetry ? firmwareTelemetry : "CONTROL PLANE · NO CAN"; row.append(text, frame); list.append(row); }); refreshIcons(); } function renderResult(result) { state.result = result; const reportMode = result.execution_report?.spec?.mode; const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback; if (feedbackSource === "simulated_hardware") { state.seatFeedbackSource = "simulated_hardware"; } else if (reportMode === "hil") { state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete ? "physical_can" : "physical_partial"; } else if (result.function_configs?.seat) { state.seatFeedbackSource = "simulated"; } const executionSucceeded = ["succeeded", "partially_succeeded"].includes(result.status); const domains = Object.keys(result.function_configs || {}); const domainLabels = { seat: "座椅", climate: "空调", roof: "天幕", ambient_light: "氛围灯", }; $("#resultTitle").textContent = executionSucceeded ? result.mode_profile ? "长期模式配置已保存" : result.preset_config ? "座椅姿态预设已保存" : result.scene_config ? `${domains.length} 域场景闭环完成` : `${domainLabels[domains[0]] || "车舱"}单域闭环完成` : "请求未执行"; const conversationLabel = result.conversation_id?.slice(-8) || "no-context"; $("#requestMeta").textContent = `${result.request_id} / context:${conversationLabel} / turn:${result.conversation_turn} / ${result.provider_used || result.provider_requested}`; $("#requestMeta").textContent += ` / ${result.execution_report?.spec?.mode || result.execution_mode || "dry_run"}`; setRunStatus( executionSucceeded ? "succeeded" : "failed", result.status.toUpperCase(), ); renderPipeline(result.trace_events || []); renderDecision(result); renderTrace(result.trace_events || []); renderValidation(result.validation || []); renderSeatState(result.actuator_state); updateSeatFeedbackModeLabel(); renderDomainState(result.domain_state); updateTwinExplicitAngles(result); renderSavedPresets(result); if (result.mode_profile) loadRuntime(); renderCode(result); renderExecution(result); window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result })); } async function submitRequest(event) { event.preventDefault(); const utterance = $("#utterance").value.trim(); if (!utterance) { showToast("请输入车舱控制请求"); return; } const button = $("#runButton"); button.disabled = true; button.classList.add("loading"); button.innerHTML = '处理中'; refreshIcons(); setRunStatus("processing", "PROCESSING"); $("#resultTitle").textContent = "正在执行车舱编排"; startProgress(); const approvalTokenInput = $("#approvalToken"); const approvalRequired = state.executionMode === "hardware" && Boolean(state.runtime?.hardware?.approval_required); const approvalToken = approvalRequired ? approvalTokenInput?.value || "" : null; if (approvalTokenInput) approvalTokenInput.value = ""; try { const response = await apiRequest("/api/requests", { method: "POST", timeoutMs: requestTimeoutForExecutionMode(state.executionMode), headers: { "Content-Type": "application/json" }, body: JSON.stringify({ utterance, provider: state.provider, conversation_id: state.conversationId, vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, execution_mode: state.executionMode, ...(state.executionMode === "simulated_hardware" ? { simulation_fault: $("#simulationFault").value } : {}), ...(approvalRequired ? { approval_token: approvalToken } : {}), }), }); const payload = await response.json(); if (response.status === 401) { window.location.replace("/login"); return; } if (!response.ok) throw new Error(payload.message || payload.error || `HTTP ${response.status}`); renderResult(payload); if (state.executionMode === "hardware") { await synchronizePhysicalSeatState(); } const completed = ["succeeded", "partially_succeeded"].includes(payload.status); const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware"; showToast(completed ? (simulated ? "模拟台架执行完成" : payload.execution_report?.spec?.mode === "hil" ? "真实台架执行与反馈确认已完成" : "可执行动作的 dry-run 已完成") : simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行"); } catch (error) { window.clearInterval(state.progressTimer); resetPipeline(); setRunStatus("failed", "FAILED"); $("#resultTitle").textContent = "请求处理失败"; showToast(error.message); } finally { button.disabled = false; button.classList.remove("loading"); const hardwareLabel = state.runtime?.hardware?.approval_required ? "授权并真实执行" : "真实执行"; const buttonLabel = state.executionMode === "hardware" ? hardwareLabel : state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行"; button.innerHTML = `${buttonLabel}`; refreshIcons(); } } function activateTab(tabName) { $$(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === tabName)); const panel = $(`#panel-${tabName}`); if (!panel) return; panel.classList.add("navigation-focus"); panel.scrollIntoView({ behavior: "smooth", block: "start" }); window.setTimeout(() => panel.classList.remove("navigation-focus"), 900); } function initResultWorkspace() { const panels = $$(".result-workspace .tab-panel"); panels.forEach((panel) => { panel.hidden = false; panel.classList.add("active"); }); if (!("IntersectionObserver" in window)) return; const observer = new IntersectionObserver((entries) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0]; if (!visible) return; const tabName = visible.target.id.replace("panel-", ""); $$(".result-nav .tab").forEach((tab) => { tab.classList.toggle("active", tab.dataset.tab === tabName); }); }, { rootMargin: "-18% 0px -56% 0px", threshold: [0.08, 0.3, 0.6] }); panels.forEach((panel) => observer.observe(panel)); } async function copyArtifact(type) { const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {}); if (!value) return showToast("当前没有可复制的内容"); await navigator.clipboard.writeText(toYaml(value)); showToast("已复制到剪贴板"); } function downloadArtifact(type) { const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {}); if (!value) return showToast("当前没有可下载的内容"); const blob = new Blob([toYaml(value)], { type: "text/yaml;charset=utf-8" }); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = type === "config" ? (state.result?.preset_config ? "seat-preset.yaml" : state.result?.scene_config ? "cabin-scene-bundle.yaml" : "function-config.yaml") : "command-plans.yaml"; link.click(); URL.revokeObjectURL(link.href); } function startNewConversation() { state.conversationId = createConversationId(); sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId); window.location.reload(); } window.CognitiveAgentConsole = { executeUtterance(utterance, { autoSubmit = true } = {}) { const input = $("#utterance"); const button = $("#runButton"); if (!input || !button || button.disabled) return false; input.value = String(utterance || "").trim(); input.dispatchEvent(new Event("input", { bubbles: true })); input.focus({ preventScroll: true }); if (autoSubmit) $("#requestForm").requestSubmit(); return true; }, getExecutionMode() { return state.executionMode; }, getResult() { return state.result; }, notify(message) { showToast(String(message)); }, }; window.addEventListener("cognitive-agent:connection", (event) => { const previousStatus = state.connectionStatus; state.connectionStatus = event.detail?.status || "offline"; if (["offline", "degraded"].includes(previousStatus) && state.connectionStatus === "online") { Promise.all([loadAuthStatus(), loadRuntime()]) .then(() => showToast("服务连接已恢复")) .catch(() => {}); } }); document.addEventListener("DOMContentLoaded", async () => { initJointTwinLayout(); initResultWorkspace(); refreshIcons(); initSeatTwin(); $("#requestForm").addEventListener("submit", submitRequest); $("#utterance").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); $("#requestForm").requestSubmit(); } }); $("#newConversation").addEventListener("click", startNewConversation); $("#logoutButton").addEventListener("click", logout); $("#vehicleSelect").addEventListener("change", (event) => { selectVehicle(event.target.value); }); $(".mode-chips").addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; const button = target?.closest("button[data-mode-command]"); if (button) applyModeCommand(button.dataset.modeCommand); }); $("#saveModeToggle").addEventListener("click", toggleSaveModeComposer); $("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile); $("#saveModeName").addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.isComposing) { event.preventDefault(); saveCurrentModeProfile(); } }); $$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider))); $$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode))); $$(".example-command").forEach((button) => button.addEventListener("click", () => { $("#utterance").value = button.dataset.example; })); $$(".tab").forEach((tab) => tab.addEventListener("click", () => activateTab(tab.dataset.tab))); $$('[data-copy]').forEach((button) => button.addEventListener("click", () => copyArtifact(button.dataset.copy))); $$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download))); await loadAuthStatus(); await loadRuntime(); await synchronizePhysicalSeatState(); if (new URLSearchParams(window.location.search).get("autorun") === "1") { $("#requestForm").requestSubmit(); } });