Files
Cognitive_Agent/apps/web-console/app.js
T

4995 lines
190 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function 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;
}
function loadHardwareClientId() {
const key = "cognitive-agent.hardware-client-id";
const stored = localStorage.getItem(key);
if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
const clientId = `hwc-${crypto.randomUUID()}`;
localStorage.setItem(key, clientId);
return clientId;
}
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",
hardwareClientId: loadHardwareClientId(),
hardwareAccess: null,
hardwareAccessPoller: null,
};
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 = `
<div>
<span class="section-kicker">VEHICLE DIGITAL TWIN · JOINT CONTROL</span>
<h3 id="jointTwinTitle">整车联合孪生总览</h3>
<p>完整五座跨界车孪生:主驾与后排固定,副驾支持姿态、通风和加热动态反馈。</p>
</div>
<div class="joint-scope" aria-label="联合控制范围">
<span><i data-lucide="armchair"></i>副驾座椅</span>
<i data-lucide="plus"></i>
<span><i data-lucide="fan"></i>空调</span>
<i data-lucide="plus"></i>
<span><i data-lucide="lamp-ceiling"></i>氛围灯</span>
<i data-lucide="plus"></i>
<span><i data-lucide="panels-top-left"></i>天幕</span>
</div>`;
cabinBand.before(shell);
shell.append(heading, twinBand, cabinBand);
passengerCard.classList.add("joint-seat-panel", "domain-panel");
const seatFeedback = $("#seatFeedbackModeLabel");
if (seatFeedback) passengerCard.prepend(seatFeedback);
domainGrid.prepend(passengerCard);
const updateDomainGridScrollState = () => {
const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2;
const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2;
domainGrid.dataset.scrollable = String(isScrollable);
domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom);
};
domainGrid.addEventListener("scroll", updateDomainGridScrollState, { passive: true });
if ("ResizeObserver" in window) {
const domainGridObserver = new ResizeObserver(updateDomainGridScrollState);
domainGridObserver.observe(domainGrid);
}
requestAnimationFrame(updateDomainGridScrollState);
coverage.classList.add("joint-hardware-boundary");
shell.append(coverage);
stateBand.remove();
}
function twinAngleForPosition(axis, positionPct) {
const range = TWIN_ANGLE_RANGES[axis];
if (!range) return null;
return range[0] + (range[1] - range[0]) * Number(positionPct) / 100;
}
function isEstimatedMotion(device, axis) {
const motion = device?.position?.[axis];
return Boolean(motion?.position_estimated || motion?.position_valid === false);
}
function twinPostureAngles(device) {
const position = (axis, fallback) => Number(
device?.position?.[axis]?.position_pct ?? fallback,
);
const included = (axis, fallback) => Number(
device?.position?.[axis]?.angle_deg
?? twinAngleForPosition(axis, position(axis, fallback)),
);
const cushion = position("cushion_tilt", 50) * 0.09;
return {
cushion_tilt: cushion,
backrest_recline: included("backrest_recline", 20)
- TWIN_ANGLE_RANGES.backrest_recline[0],
legrest_height: included("legrest_height", 0)
- TWIN_ANGLE_RANGES.legrest_height[0],
};
}
function makeDynamicLine(THREE, parent, pointCount, material, segments = false) {
const positions = new Float32Array(pointCount * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const line = segments
? new THREE.LineSegments(geometry, material)
: new THREE.Line(geometry, material);
line.frustumCulled = false;
line.renderOrder = 20;
parent.add(line);
return { line, positions, attribute: geometry.getAttribute("position") };
}
function createTwinAngleGuide(THREE, parent, color) {
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.95,
depthTest: false,
});
const group = new THREE.Group();
parent.add(group);
const dotMaterial = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.96,
depthTest: false,
});
const dotGeometry = new THREE.SphereGeometry(0.022, 8, 8);
const dots = Array.from({ length: 13 }, () => {
const dot = new THREE.Mesh(dotGeometry, dotMaterial);
dot.renderOrder = 21;
group.add(dot);
return dot;
});
dots[0].scale.setScalar(1.6);
dots[dots.length - 1].scale.setScalar(1.6);
return {
group,
arc: makeDynamicLine(THREE, group, 49, material),
rays: makeDynamicLine(THREE, group, 4, material, true),
dots,
};
}
function updateTwinAngleGuide(guide, x, pivotY, pivotZ, startDeg, sweepDeg, radius) {
for (let index = 0; index < 49; index += 1) {
const angle = (startDeg + sweepDeg * index / 48) * Math.PI / 180;
const offset = index * 3;
guide.arc.positions[offset] = x;
guide.arc.positions[offset + 1] = pivotY + Math.sin(angle) * radius;
guide.arc.positions[offset + 2] = pivotZ + Math.cos(angle) * radius;
}
const endpoints = [
[x, pivotY, pivotZ],
[guide.arc.positions[0], guide.arc.positions[1], guide.arc.positions[2]],
[x, pivotY, pivotZ],
[
guide.arc.positions[guide.arc.positions.length - 3],
guide.arc.positions[guide.arc.positions.length - 2],
guide.arc.positions[guide.arc.positions.length - 1],
],
];
endpoints.flat().forEach((value, index) => {
guide.rays.positions[index] = value;
});
guide.dots.forEach((dot, index) => {
const source = index * 4 * 3;
dot.position.set(
guide.arc.positions[source],
guide.arc.positions[source + 1],
guide.arc.positions[source + 2],
);
});
guide.arc.attribute.needsUpdate = true;
guide.rays.attribute.needsUpdate = true;
}
function makeTwinBox(THREE, parent, size, position, material, edgeMaterial) {
const geometry = new THREE.BoxGeometry(...size);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 32), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeCabinBeam(THREE, parent, start, end, radius, material) {
const from = new THREE.Vector3(...start);
const to = new THREE.Vector3(...end);
const direction = to.clone().sub(from);
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, direction.length(), 12),
material,
);
beam.position.copy(from).add(to).multiplyScalar(0.5);
beam.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.normalize(),
);
beam.castShadow = true;
parent.add(beam);
return beam;
}
function createTwinAirflowField(THREE, parent) {
const count = 56;
const positions = new Float32Array(count * 3);
const seeds = Array.from({ length: count }, (_, index) => (
(index * 0.61803398875) % 1
));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x55bdec,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 24;
parent.add(points);
return {
points,
positions,
seeds,
attribute: geometry.getAttribute("position"),
material,
level: 0,
};
}
function makeVehicleProfileShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-3.72, 0.2);
shape.lineTo(-3.72, 0.92);
shape.quadraticCurveTo(-3.6, 1.28, -3.12, 1.47);
shape.quadraticCurveTo(-2.42, 1.51, -1.52, 1.45);
shape.lineTo(1.86, 1.45);
shape.quadraticCurveTo(2.06, 1.47, 2.22, 1.52);
shape.quadraticCurveTo(2.88, 1.52, 3.48, 1.28);
shape.quadraticCurveTo(3.76, 1.12, 3.74, 0.72);
shape.lineTo(3.68, 0.24);
shape.lineTo(3.12, 0.18);
shape.bezierCurveTo(3.02, 0.76, 2.8, 1.3, 2.35, 1.3);
shape.bezierCurveTo(1.9, 1.3, 1.68, 0.76, 1.58, 0.18);
shape.lineTo(-1.48, 0.18);
shape.bezierCurveTo(-1.58, 0.76, -1.8, 1.3, -2.25, 1.3);
shape.bezierCurveTo(-2.7, 1.3, -2.92, 0.76, -3.02, 0.18);
shape.lineTo(-3.72, 0.2);
shape.closePath();
return shape;
}
function makeVehicleGreenhouseShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.62, 1.31);
shape.quadraticCurveTo(-2.28, 1.8, -1.82, 2.42);
shape.quadraticCurveTo(-0.48, 2.68, 0.92, 2.5);
shape.quadraticCurveTo(1.58, 2.4, 2.18, 1.31);
shape.closePath();
return shape;
}
function makeVehicleSideGlassShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.58, 1.32);
shape.lineTo(-1.79, 2.36);
shape.quadraticCurveTo(-0.5, 2.56, 1.03, 2.42);
shape.lineTo(1.82, 1.32);
shape.closePath();
return shape;
}
function makeRoundedPanelShape(THREE, width, length, radius) {
const shape = new THREE.Shape();
const halfWidth = width / 2;
const halfLength = length / 2;
shape.moveTo(-halfWidth + radius, -halfLength);
shape.lineTo(halfWidth - radius, -halfLength);
shape.quadraticCurveTo(halfWidth, -halfLength, halfWidth, -halfLength + radius);
shape.lineTo(halfWidth, halfLength - radius);
shape.quadraticCurveTo(halfWidth, halfLength, halfWidth - radius, halfLength);
shape.lineTo(-halfWidth + radius, halfLength);
shape.quadraticCurveTo(-halfWidth, halfLength, -halfWidth, halfLength - radius);
shape.lineTo(-halfWidth, -halfLength + radius);
shape.quadraticCurveTo(-halfWidth, -halfLength, -halfWidth + radius, -halfLength);
shape.closePath();
return shape;
}
function createTwinRearBench(THREE) {
const root = new THREE.Group();
root.position.set(0, 0.12, -1.72);
root.userData.twinRole = "fixed_rear_bench";
const leather = new THREE.MeshPhysicalMaterial({
color: 0x35484e,
roughness: 0.58,
metalness: 0.01,
clearcoat: 0.12,
clearcoatRoughness: 0.75,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x829497,
roughness: 0.72,
clearcoat: 0.05,
});
const structure = new THREE.MeshStandardMaterial({
color: 0x26343a,
roughness: 0.68,
metalness: 0.25,
});
const buckleMaterial = new THREE.MeshStandardMaterial({
color: 0xb64b43,
roughness: 0.5,
});
const edge = new THREE.LineBasicMaterial({
color: 0x19272b,
transparent: true,
opacity: 0.42,
});
makeTwinBox(THREE, root, [2.5, 0.18, 0.72], [0, 0.24, 0.08], structure, edge);
const cushion = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 1.82, 7, 24), leather);
cushion.rotation.z = Math.PI / 2;
cushion.scale.set(0.92, 1, 1.23);
cushion.position.set(0, 0.46, 0.08);
cushion.castShadow = true;
cushion.receiveShadow = true;
root.add(cushion);
const backrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 1.78, 7, 24), leather);
backrest.rotation.z = Math.PI / 2;
backrest.scale.set(1.46, 1, 0.43);
backrest.position.set(0, 1.04, -0.68);
backrest.castShadow = true;
root.add(backrest);
[-0.79, 0, 0.79].forEach((x) => {
const cushionInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.13, 0.42, 6, 18),
insert,
);
cushionInsert.rotation.z = Math.PI / 2;
cushionInsert.scale.set(0.52, 1, 2.05);
cushionInsert.position.set(x, 0.71, 0.08);
cushionInsert.castShadow = true;
root.add(cushionInsert);
const backInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.42, 6, 18),
insert,
);
backInsert.scale.set(1.65, 1, 0.42);
backInsert.position.set(x, 1.05, -0.535);
backInsert.castShadow = true;
root.add(backInsert);
makeCabinBeam(THREE, root, [x - 0.11, 1.43, -0.7], [x - 0.11, 1.55, -0.7], 0.018, structure);
makeCabinBeam(THREE, root, [x + 0.11, 1.43, -0.7], [x + 0.11, 1.55, -0.7], 0.018, structure);
const headrest = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.16, 7, 18),
leather,
);
headrest.scale.set(1.25, 1, 0.55);
headrest.position.set(x, 1.62, -0.7);
headrest.castShadow = true;
root.add(headrest);
});
[-0.4, 0.4].forEach((x) => {
makeTwinBox(THREE, root, [0.09, 0.075, 0.12], [x, 0.73, -0.04], buckleMaterial, edge);
});
return root;
}
function createTwinCabin(THREE, scene) {
const cabin = new THREE.Group();
const trim = new THREE.MeshStandardMaterial({
color: 0x25343a,
roughness: 0.6,
metalness: 0.2,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
const softTrim = new THREE.MeshStandardMaterial({
color: 0x506167,
roughness: 0.8,
transparent: true,
opacity: 0.5,
depthWrite: false,
});
const cockpit = new THREE.MeshStandardMaterial({
color: 0x243238,
roughness: 0.6,
metalness: 0.18,
});
const consoleTop = new THREE.MeshStandardMaterial({
color: 0x5c6c70,
roughness: 0.66,
metalness: 0.12,
});
const accent = new THREE.MeshStandardMaterial({
color: 0x26364c,
emissive: 0x78efff,
emissiveIntensity: 0.18,
roughness: 0.22,
metalness: 0.18,
depthTest: false,
});
const ambientHousingMaterial = new THREE.MeshStandardMaterial({
color: 0x17242c,
roughness: 0.38,
metalness: 0.56,
emissive: 0x08141a,
emissiveIntensity: 0.18,
});
const ambientMeshes = [];
const invisibleEdge = new THREE.LineBasicMaterial({ transparent: true, opacity: 0 });
const shellMaterial = new THREE.MeshPhysicalMaterial({
color: 0x84adb7,
roughness: 0.18,
metalness: 0.1,
transmission: 0.48,
transparent: true,
opacity: 0.17,
depthWrite: false,
side: THREE.DoubleSide,
clearcoat: 0.9,
clearcoatRoughness: 0.18,
});
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0x6f9eae,
roughness: 0.06,
transmission: 0.86,
transparent: true,
opacity: 0.22,
depthWrite: false,
side: THREE.DoubleSide,
});
const shellEdge = new THREE.LineBasicMaterial({
color: 0x456975,
transparent: true,
opacity: 0.56,
});
const roofEntityEdge = new THREE.LineBasicMaterial({
color: 0x086861,
transparent: true,
opacity: 0.98,
depthTest: true,
});
const ventEntityEdge = new THREE.LineBasicMaterial({
color: 0x9eeaff,
transparent: true,
opacity: 1,
depthTest: false,
});
const bodyShell = makeTwinPanel(
THREE,
cabin,
makeVehicleProfileShape(THREE),
3.16,
[0, 0, 0],
[0, -Math.PI / 2, 0],
shellMaterial,
shellEdge,
);
bodyShell.castShadow = false;
bodyShell.receiveShadow = false;
bodyShell.renderOrder = 2;
const greenhouseMaterial = glassMaterial.clone();
greenhouseMaterial.color.setHex(0x769ead);
greenhouseMaterial.opacity = 0.12;
const greenhouse = makeTwinPanel(
THREE,
cabin,
makeVehicleGreenhouseShape(THREE),
2.64,
[0, 0, 0],
[0, -Math.PI / 2, 0],
greenhouseMaterial,
shellEdge,
);
greenhouse.castShadow = false;
greenhouse.renderOrder = 3;
const bodyPanelMaterial = shellMaterial.clone();
bodyPanelMaterial.color.setHex(0x6f9da8);
bodyPanelMaterial.transmission = 0.34;
bodyPanelMaterial.opacity = 0.3;
const exteriorTrim = new THREE.MeshStandardMaterial({
color: 0x1e2a2f,
roughness: 0.52,
metalness: 0.3,
});
const hood = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 2.68, 1.44, 0.18),
0.05,
[0, 1.43, 2.88],
[-Math.PI / 2 + 0.1, 0, 0],
bodyPanelMaterial,
shellEdge,
);
hood.castShadow = false;
hood.renderOrder = 3;
makeTwinBox(THREE, cabin, [2.82, 0.42, 0.14], [0, 0.57, 3.57], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [2.82, 0.38, 0.14], [0, 0.54, -3.55], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [1.1, 0.18, 0.055], [0, 0.61, 3.655], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.68, 0.055, 0.035], [0, 0.82, -3.635], exteriorTrim, invisibleEdge);
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.12, 0.2, 2.88], [side * 1.5, 0.34, 0.02], bodyPanelMaterial, shellEdge);
makeCabinBeam(THREE, cabin, [side * 1.615, 1.3, 1.82], [side * 1.615, 1.3, -2.56], 0.012, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.4, 0.18], [side * 1.62, 1.29, 0.18], 0.01, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.38, -1.48], [side * 1.62, 1.29, -1.48], 0.01, exteriorTrim);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, 0.58], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, -1.2], exteriorTrim, invisibleEdge);
const mirror = new THREE.Mesh(new THREE.SphereGeometry(0.18, 18, 12), bodyPanelMaterial);
mirror.scale.set(0.56, 0.68, 1.24);
mirror.position.set(side * 1.76, 1.48, 1.3);
mirror.castShadow = true;
cabin.add(mirror);
makeCabinBeam(THREE, cabin, [side * 1.52, 1.4, 1.31], [side * 1.68, 1.46, 1.3], 0.025, exteriorTrim);
}
[-1, 1].forEach((side) => {
const sideGlass = makeTwinPanel(
THREE,
cabin,
makeVehicleSideGlassShape(THREE),
0.025,
[side * 1.335, 0, 0],
[0, -Math.PI / 2, 0],
glassMaterial,
shellEdge,
);
sideGlass.castShadow = false;
sideGlass.renderOrder = 4;
});
const windshield = makeTwinBox(
THREE,
cabin,
[2.48, 1.43, 0.035],
[0, 1.88, 1.57],
glassMaterial,
shellEdge,
);
windshield.rotation.x = -0.49;
windshield.castShadow = false;
windshield.renderOrder = 4;
const rearGlass = makeTwinBox(
THREE,
cabin,
[2.38, 1.28, 0.035],
[0, 1.88, -2.26],
glassMaterial,
shellEdge,
);
rearGlass.rotation.x = 0.6;
rearGlass.castShadow = false;
rearGlass.renderOrder = 4;
const roofGlassMaterial = glassMaterial.clone();
roofGlassMaterial.color.setHex(0x258f89);
roofGlassMaterial.emissive.setHex(0x124c49);
roofGlassMaterial.emissiveIntensity = 0.38;
roofGlassMaterial.transmission = 0;
roofGlassMaterial.transparent = true;
roofGlassMaterial.opacity = 0.52;
roofGlassMaterial.depthWrite = false;
const roofGlass = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 1.08, 1.96, 0.16),
0.038,
[0, 2.43, -0.61],
[-Math.PI / 2, 0, 0],
roofGlassMaterial,
roofEntityEdge,
);
roofGlass.castShadow = false;
roofGlass.renderOrder = 7;
const roofFrameMaterial = new THREE.MeshStandardMaterial({
color: 0x086861,
emissive: 0x124c49,
emissiveIntensity: 0.38,
roughness: 0.24,
metalness: 0.62,
transparent: true,
opacity: 0.88,
depthWrite: false,
depthTest: true,
});
const roofMeshes = [roofGlass];
[
[[1.22, 0.055, 0.075], [0, 2.455, 0.405]],
[[1.22, 0.055, 0.075], [0, 2.455, -1.625]],
[[0.075, 0.055, 1.96], [-0.59, 2.455, -0.61]],
[[0.075, 0.055, 1.96], [0.59, 2.455, -0.61]],
].forEach(([size, position]) => {
roofMeshes.push(makeTwinBox(
THREE,
cabin,
size,
position,
roofFrameMaterial,
invisibleEdge,
));
});
const roofShadeMaterial = new THREE.MeshStandardMaterial({
color: 0x5f6d71,
roughness: 0.86,
transparent: true,
opacity: 0.06,
depthWrite: false,
});
const roofShade = makeTwinBox(
THREE,
cabin,
[1.02, 0.025, 1.88],
[0, 2.39, -1.55],
roofShadeMaterial,
invisibleEdge,
);
roofShade.scale.z = 0.015;
roofShade.renderOrder = 6;
for (const side of [-1, 1]) {
makeCabinBeam(THREE, cabin, [side * 1.47, 1.25, 1.9], [side * 1.25, 2.51, 1.19], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.5, 1.25, -2.55], [side * 1.25, 2.48, -1.78], 0.05, trim);
makeCabinBeam(THREE, cabin, [side * 1.56, 1.25, -0.32], [side * 1.34, 2.54, -0.32], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.24, 2.57, 1.27], [side * 1.24, 2.57, -1.84], 0.045, trim);
}
makeCabinBeam(THREE, cabin, [-1.23, 2.58, 1.3], [1.23, 2.58, 1.3], 0.045, trim);
makeCabinBeam(THREE, cabin, [-1.23, 2.58, -1.9], [1.23, 2.58, -1.9], 0.045, trim);
const cabinFloor = makeTwinBox(
THREE,
cabin,
[2.86, 0.08, 5.45],
[0, 0.13, -0.05],
new THREE.MeshStandardMaterial({ color: 0x2b393e, roughness: 0.92, transparent: true, opacity: 0.5 }),
invisibleEdge,
);
cabinFloor.receiveShadow = true;
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.065, 0.62, 3.34], [side * 1.46, 0.67, -0.18], softTrim, invisibleEdge);
makeCabinBeam(THREE, cabin, [side * 1.415, 1.02, 1.3], [side * 1.415, 1.02, -1.76], 0.012, trim);
}
makeTwinBox(THREE, cabin, [0.42, 0.36, 1.55], [0, 0.4, 0.42], cockpit, invisibleEdge);
makeTwinBox(THREE, cabin, [0.38, 0.035, 1.28], [0, 0.6, 0.4], consoleTop, invisibleEdge);
const dashboard = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 2.55, 7, 28), softTrim);
dashboard.rotation.z = Math.PI / 2;
dashboard.position.set(0, 1.38, 1.63);
dashboard.scale.z = 1.5;
dashboard.castShadow = true;
cabin.add(dashboard);
const gloveboxLightHousing = makeTwinBox(
THREE,
cabin,
[1.18, 0.135, 0.075],
[-0.75, 1.265, 1.43],
ambientHousingMaterial,
invisibleEdge,
);
ambientMeshes.push(gloveboxLightHousing);
for (let index = 0; index < 9; index += 1) {
const lightSegment = makeTwinBox(
THREE,
cabin,
[0.09, 0.042, 0.028],
[-1.21 + index * 0.115, 1.315, 1.385],
accent,
invisibleEdge,
);
lightSegment.userData.twinRole = "passenger_glovebox_ambient_light";
ambientMeshes.push(lightSegment);
}
makeTwinBox(THREE, cabin, [0.54, 0.3, 0.055], [0.82, 1.62, 1.49], cockpit, invisibleEdge);
const steeringWheel = new THREE.Group();
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.036, 10, 36), cockpit);
steeringWheel.add(wheel);
makeTwinBox(THREE, steeringWheel, [0.035, 0.22, 0.028], [0, -0.1, 0], cockpit, invisibleEdge);
makeTwinBox(THREE, steeringWheel, [0.23, 0.035, 0.028], [0, -0.02, 0], cockpit, invisibleEdge);
steeringWheel.position.set(0.82, 1.48, 1.24);
steeringWheel.rotation.x = -0.34;
cabin.add(steeringWheel);
makeCabinBeam(THREE, cabin, [0.82, 1.43, 1.27], [0.82, 1.34, 1.57], 0.024, cockpit);
const tireMaterial = new THREE.MeshStandardMaterial({
color: 0x172126,
roughness: 0.92,
metalness: 0.05,
});
const rimMaterial = new THREE.MeshStandardMaterial({
color: 0x798a8f,
roughness: 0.38,
metalness: 0.72,
});
[-1, 1].forEach((side) => {
[-2.25, 2.35].forEach((z) => {
const tire = new THREE.Mesh(
new THREE.TorusGeometry(0.53, 0.15, 14, 32),
tireMaterial,
);
tire.position.set(side * 1.49, 0.58, z);
tire.rotation.y = Math.PI / 2;
tire.castShadow = true;
cabin.add(tire);
const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.11, 24), rimMaterial);
rim.position.copy(tire.position);
rim.rotation.z = Math.PI / 2;
cabin.add(rim);
const archLip = new THREE.Mesh(
new THREE.TorusGeometry(0.69, 0.035, 8, 36, Math.PI),
bodyPanelMaterial,
);
archLip.position.set(side * 1.615, 0.58, z);
archLip.rotation.y = Math.PI / 2;
archLip.castShadow = true;
cabin.add(archLip);
const spokeX = side * 1.555;
for (let spoke = 0; spoke < 5; spoke += 1) {
const angle = spoke * Math.PI * 2 / 5;
makeCabinBeam(
THREE,
cabin,
[spokeX, 0.58, z],
[spokeX, 0.58 + Math.cos(angle) * 0.27, z + Math.sin(angle) * 0.27],
0.022,
rimMaterial,
);
}
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 8), exteriorTrim);
hub.position.set(spokeX, 0.58, z);
hub.scale.x = 0.45;
cabin.add(hub);
});
});
const lampMaterial = new THREE.MeshStandardMaterial({
color: 0x5d7076,
emissive: 0xb9ecff,
emissiveIntensity: 0.08,
roughness: 0.34,
});
const rearLampMaterial = new THREE.MeshStandardMaterial({
color: 0x697b80,
emissive: 0x000000,
emissiveIntensity: 0,
roughness: 0.56,
});
const headlampMeshes = [];
for (const side of [-1, 1]) {
const headlamp = new THREE.Mesh(new THREE.SphereGeometry(0.16, 18, 10), lampMaterial);
headlamp.scale.set(2.6, 0.75, 0.24);
headlamp.position.set(side * 1.03, 1.05, 3.61);
cabin.add(headlamp);
headlampMeshes.push(headlamp);
const rearLamp = new THREE.Mesh(new THREE.SphereGeometry(0.15, 18, 10), rearLampMaterial);
rearLamp.scale.set(2.35, 0.7, 0.22);
rearLamp.position.set(side * 1.08, 1.08, -3.57);
cabin.add(rearLamp);
}
makeTwinBox(THREE, cabin, [1.08, 0.22, 0.035], [0, 0.72, 3.69], cockpit, invisibleEdge);
const ventMaterials = [];
const ventMeshes = [];
const ventCoreMaterial = new THREE.MeshStandardMaterial({
color: 0x07141c,
roughness: 0.5,
metalness: 0.34,
});
const ventBladeMaterial = new THREE.MeshStandardMaterial({
color: 0xd8f2f7,
emissive: 0x62dfff,
emissiveIntensity: 0.48,
roughness: 0.2,
metalness: 0.82,
depthTest: false,
});
[-1.14, -0.34, 0.34, 1.14].forEach((x) => {
const ventMaterial = new THREE.MeshStandardMaterial({
color: 0x176c94,
roughness: 0.24,
metalness: 0.55,
emissive: 0x2ccff3,
emissiveIntensity: 0.62,
});
ventMaterials.push(ventMaterial);
const vent = makeTwinBox(
THREE,
cabin,
[Math.abs(x) > 1 ? 0.47 : 0.44, 0.2, 0.135],
[x, 1.38, 1.405],
ventMaterial,
ventEntityEdge,
);
vent.userData.twinRole = "dashboard_hvac_outlet";
ventMeshes.push(vent);
const coreWidth = Math.abs(x) > 1 ? 0.365 : 0.34;
makeTwinBox(
THREE,
vent,
[coreWidth, 0.12, 0.145],
[0, 0, 0],
ventCoreMaterial,
invisibleEdge,
);
for (const bladeY of [-0.038, 0, 0.038]) {
makeTwinBox(
THREE,
vent,
[coreWidth * 0.9, 0.014, 0.025],
[0, bladeY, -0.083],
ventBladeMaterial,
invisibleEdge,
);
}
});
const rearVentMaterial = new THREE.MeshStandardMaterial({
color: 0x203741,
roughness: 0.34,
metalness: 0.42,
emissive: 0x2d91b6,
emissiveIntensity: 0.3,
});
ventMaterials.push(rearVentMaterial);
[-0.1, 0.1].forEach((x) => {
const vent = makeTwinBox(
THREE,
cabin,
[0.18, 0.13, 0.065],
[x, 0.48, -0.375],
rearVentMaterial,
ventEntityEdge,
);
ventMeshes.push(vent);
});
const airflow = createTwinAirflowField(THREE, cabin);
const ambientLights = [
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
];
ambientLights[0].position.set(-1.06, 1.22, 1.08);
ambientLights[1].position.set(-0.48, 1.22, 1.08);
ambientLights.forEach((light) => cabin.add(light));
const fallbackExteriorMaterials = new Set([
shellMaterial,
greenhouseMaterial,
bodyPanelMaterial,
exteriorTrim,
glassMaterial,
tireMaterial,
lampMaterial,
rearLampMaterial,
]);
const fallbackExteriorObjects = [];
const fallbackInteriorMaterials = new Set([trim, softTrim, cockpit, consoleTop]);
const fallbackInteriorObjects = [];
const worldPosition = new THREE.Vector3();
cabin.updateMatrixWorld(true);
cabin.traverse((object) => {
if (!object.isMesh) return;
const isFallbackMaterial = fallbackExteriorMaterials.has(object.material);
object.getWorldPosition(worldPosition);
const isWheelDetail = (
object.material === rimMaterial
&& Math.abs(worldPosition.z) > 1.8
);
if (isFallbackMaterial || isWheelDetail) fallbackExteriorObjects.push(object);
if (fallbackInteriorMaterials.has(object.material) || object === cabinFloor) {
fallbackInteriorObjects.push(object);
}
});
scene.add(cabin);
return {
root: cabin,
fallbackExteriorObjects,
fallbackInteriorObjects,
shellMaterial,
glassMaterial,
referenceWindowPanes: {
front_left: [],
front_right: [],
rear_left: [],
rear_right: [],
},
roofGlass,
roofMeshes,
roofGlassMaterial,
roofFrameMaterial,
roofEdgeMaterial: roofEntityEdge,
roofShade,
roofShadeMaterial,
ambientMaterial: accent,
ambientMeshes,
ambientLights,
headlampMaterial: lampMaterial,
headlampMeshes,
referenceFrontLampMaterials: [],
referenceFrontLampMeshes: [],
referenceFrontLampHighlights: [],
referenceFrontLampLensOutlines: [],
ventMaterials,
ventMeshes,
airflow,
colors: {
ambientTarget: new THREE.Color(0x74d9ff),
ambientIdle: new THREE.Color(0x17323a),
climateTarget: new THREE.Color(0x55bdec),
roofClear: new THREE.Color(0x1f68b6),
roofDark: new THREE.Color(0x071b3e),
},
targets: {
ambientPower: 0,
ambientBrightness: 0,
climateLevel: 0,
climateTemperature: 24,
roofTransmittance: 100,
roofShadePosition: 0,
windowPositions: {
front_left: 0,
front_right: 0,
rear_left: 0,
rear_right: 0,
},
lowBeam: 0,
highBeam: 0,
fogLight: 0,
turnSignal: 0,
},
current: {
ambientBrightness: 0,
climateLevel: 0,
roofTransmittance: 100,
roofShadePosition: 0,
windowPositions: {
front_left: 0,
front_right: 0,
rear_left: 0,
rear_right: 0,
},
lowBeam: 0,
highBeam: 0,
fogLight: 0,
turnSignal: 0,
},
layout: {
roofShadeCenterZ: -0.61,
roofShadeHalfLength: 0.94,
},
};
}
const TWIN_THEME_PALETTES = {
user: {
glass: 0x258f89,
glassEmissive: 0x124c49,
glassEdge: 0x086861,
glassGlow: 0x49e1d6,
roofClear: 0x4aa9a3,
roofDark: 0x0a5552,
lamp: 0x087b74,
lampEmissive: 0x16aaa1,
lampEdge: 0x096d67,
},
admin: {
glass: 0x477bc4,
glassEmissive: 0x1f427c,
glassEdge: 0x234f91,
glassGlow: 0x86b3ff,
roofClear: 0x7299d5,
roofDark: 0x1d3c72,
lamp: 0x315eaa,
lampEmissive: 0x5688dc,
lampEdge: 0x244a86,
},
};
function currentTwinThemeRole() {
return document.body.dataset.demoRole === "admin"
|| sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin"
: "user";
}
function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) {
if (!systems) return;
const themeRole = role === "admin" ? "admin" : "user";
const palette = TWIN_THEME_PALETTES[themeRole];
systems.themeRole = themeRole;
systems.themePalette = palette;
systems.colors.roofClear.setHex(palette.roofClear);
systems.colors.roofDark.setHex(palette.roofDark);
if (systems.roofGlassMaterial.color) systems.roofGlassMaterial.color.setHex(palette.glass);
if (systems.roofGlassMaterial.emissive) {
systems.roofGlassMaterial.emissive.setHex(palette.glassEmissive);
}
if (systems.roofFrameMaterial?.color) systems.roofFrameMaterial.color.setHex(palette.glassEdge);
if (systems.roofFrameMaterial?.emissive) {
systems.roofFrameMaterial.emissive.setHex(palette.glassEmissive);
}
if (systems.roofEdgeMaterial?.color) systems.roofEdgeMaterial.color.setHex(palette.glassEdge);
Object.values(systems.referenceWindowPanes).flat().forEach((pane) => {
(pane.materials || []).forEach((material) => {
if (material.color) material.color.setHex(palette.glass);
if (material.emissive) material.emissive.setHex(palette.glassEmissive);
});
if (pane.outline?.material?.color) pane.outline.material.color.setHex(palette.glassEdge);
});
systems.referenceFrontLampMaterials.forEach((material) => {
if (material.color) material.color.setHex(palette.lamp);
if (material.emissive) material.emissive.setHex(palette.lampEmissive);
});
systems.referenceFrontLampHighlights.forEach(({ material, isHalo }) => {
if (material.color) material.color.setHex(isHalo ? palette.glassGlow : palette.lamp);
});
systems.referenceFrontLampLensOutlines.forEach(({ mesh, outline }) => {
if (mesh.material?.color) mesh.material.color.setHex(palette.lamp);
if (outline?.material?.color) outline.material.color.setHex(palette.lampEdge);
});
}
function updateTwinVehicleVisual(systems, timestamp) {
if (!systems) return;
const ease = (current, target, factor = 0.08) => (
Math.abs(target - current) < 0.002 ? target : current + (target - current) * factor
);
systems.current.ambientBrightness = ease(
systems.current.ambientBrightness,
systems.targets.ambientPower ? systems.targets.ambientBrightness : 0,
);
systems.current.climateLevel = ease(
systems.current.climateLevel,
systems.targets.climateLevel,
);
systems.current.roofTransmittance = ease(
systems.current.roofTransmittance,
systems.targets.roofTransmittance,
);
systems.current.roofShadePosition = ease(
systems.current.roofShadePosition,
systems.targets.roofShadePosition,
);
Object.keys(systems.current.windowPositions).forEach((key) => {
systems.current.windowPositions[key] = ease(
systems.current.windowPositions[key],
systems.targets.windowPositions[key],
0.1,
);
});
["lowBeam", "highBeam", "fogLight", "turnSignal"].forEach((key) => {
systems.current[key] = ease(systems.current[key], systems.targets[key], 0.12);
});
const ambientStrength = systems.current.ambientBrightness / 100;
const ambientDisplayColor = systems.colors.ambientIdle.clone().lerp(
systems.colors.ambientTarget,
0.34 + ambientStrength * 0.66,
);
systems.ambientMaterial.color.lerp(ambientDisplayColor, 0.1);
systems.ambientMaterial.emissive.lerp(systems.colors.ambientTarget, 0.1);
systems.ambientMaterial.emissiveIntensity = 0.28 + ambientStrength * 2.15;
systems.ambientMaterial.transparent = false;
systems.ambientMaterial.opacity = 1;
systems.ambientMaterial.depthWrite = true;
systems.ambientLights.forEach((light) => {
light.color.lerp(systems.colors.ambientTarget, 0.1);
light.intensity = ambientStrength * 2.4;
});
const roofRatio = systems.current.roofTransmittance / 100;
const roofColor = systems.colors.roofDark.clone().lerp(systems.colors.roofClear, roofRatio);
systems.roofGlassMaterial.color.lerp(roofColor, 0.08);
systems.roofGlassMaterial.opacity = 0.52 + (1 - roofRatio) * 0.18;
const shadeCoverage = Math.max(0.015, systems.current.roofShadePosition / 100);
systems.roofShade.scale.z = shadeCoverage;
systems.roofShade.position.z = systems.layout.roofShadeCenterZ
- (1 - shadeCoverage) * systems.layout.roofShadeHalfLength;
systems.roofShadeMaterial.opacity = 0.08 + shadeCoverage * 0.82;
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
const openingRatio = systems.current.windowPositions[key] / 100;
panes.forEach((pane) => {
pane.mesh.position.lerpVectors(pane.closedPosition, pane.openPosition, openingRatio);
});
});
const mainLampStrength = Math.max(
systems.current.lowBeam * 0.62,
systems.current.highBeam,
);
const isHighBeam = systems.current.highBeam > systems.current.lowBeam * 0.62;
const fogStrength = systems.current.fogLight;
const turnPulse = systems.targets.turnSignal
? 0.2 + 0.8 * (Math.sin(timestamp * 0.008) > 0 ? 1 : 0)
: systems.current.turnSignal;
const lampStrength = Math.max(mainLampStrength, fogStrength * 0.72, turnPulse);
const lampColor = turnPulse > Math.max(mainLampStrength, fogStrength * 0.72)
? 0xffa627
: (fogStrength * 0.72 > mainLampStrength ? 0xfff0b3 : (isHighBeam ? 0xeafaff : 0xcdefff));
const updateFrontLampMaterial = (material, isReference = false) => {
const isLit = lampStrength > 0.02;
const palette = systems.themePalette || TWIN_THEME_PALETTES.user;
if (material.color) material.color.setHex(isLit ? lampColor : palette.lamp);
if (material.emissive) material.emissive.setHex(isLit ? lampColor : palette.lampEmissive);
if ("emissiveIntensity" in material) {
material.emissiveIntensity = (isReference ? 0.18 : 0.08) + lampStrength * 3.4;
}
if (isReference) {
material.transparent = true;
material.opacity = 0.9 + lampStrength * 0.1;
material.depthWrite = lampStrength > 0.55;
}
material.needsUpdate = true;
};
updateFrontLampMaterial(systems.headlampMaterial);
systems.referenceFrontLampMaterials.forEach((material) => updateFrontLampMaterial(material, true));
systems.referenceFrontLampHighlights.forEach(({
material,
idleOpacity,
activeOpacity,
isHalo,
}) => {
const palette = systems.themePalette || TWIN_THEME_PALETTES.user;
const idleColor = isHalo ? palette.glassGlow : palette.lamp;
material.color.setHex(lampStrength > 0.02 ? lampColor : idleColor);
material.opacity = idleOpacity + lampStrength * (activeOpacity - idleOpacity);
});
const climateStrength = systems.current.climateLevel / 7;
const climateColor = systems.targets.climateTemperature < 24
? 0x55bdec
: systems.targets.climateTemperature > 24
? 0xffa05d
: 0x72d1d0;
systems.colors.climateTarget.setHex(climateColor);
systems.airflow.level = ease(systems.airflow.level, climateStrength, 0.1);
systems.airflow.material.color.lerp(systems.colors.climateTarget, 0.12);
systems.airflow.material.opacity = systems.airflow.level * 0.92;
systems.airflow.material.size = 0.04 + systems.airflow.level * 0.055;
systems.airflow.points.visible = systems.airflow.level > 0.01;
systems.ventMaterials.forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.emissive.lerp(systems.colors.climateTarget, 0.12);
material.emissiveIntensity = 0.3 + systems.airflow.level * 2;
});
const speed = 0.00018 + systems.airflow.level * 0.00042;
systems.airflow.seeds.forEach((seed, index) => {
const progress = (timestamp * speed + seed) % 1;
const lane = index % 8;
const offset = index * 3;
systems.airflow.positions[offset] = -1.22 + lane * 0.13 + Math.sin(progress * 9 + index) * 0.035;
systems.airflow.positions[offset + 1] = 1.35 - progress * 0.5 + Math.sin(progress * Math.PI) * 0.13;
systems.airflow.positions[offset + 2] = 1.38 - progress * 1.68;
});
systems.airflow.attribute.needsUpdate = true;
}
function makeTwinPanel(THREE, parent, shape, depth, position, rotation, material, edgeMaterial) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 1,
curveSegments: 18,
bevelEnabled: true,
bevelSegments: 4,
bevelSize: 0.045,
bevelThickness: 0.04,
});
geometry.translate(0, 0, -depth / 2);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.rotation.set(...rotation);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 48), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeBackrestShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.7 - inset;
shape.moveTo(-0.52 + inset, 0);
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
shape.lineTo(-half + 0.035, 1.28 - inset);
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
return shape;
}
function makeCushionShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.69 - inset;
shape.moveTo(-0.52 + inset, 0.61 - inset);
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
shape.lineTo(-half + 0.025, -0.39 + inset);
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
return shape;
}
function makeTwinSeam(THREE, parent, points, material) {
const geometry = new THREE.BufferGeometry().setFromPoints(
points.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
);
const seam = new THREE.Line(geometry, material);
parent.add(seam);
return seam;
}
function createTwinVentField(THREE, parent, kind) {
const count = 12;
const positions = new Float32Array(count * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x39bdf2,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 18;
parent.add(points);
return {
kind,
points,
positions,
attribute: geometry.getAttribute("position"),
material,
};
}
function createTwinHeatPulse(THREE, parent, kind, phase) {
const material = new THREE.MeshBasicMaterial({
color: 0xff633f,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(
new THREE.RingGeometry(0.17, 0.205, 32),
material,
);
if (kind === "backrest") {
mesh.position.set(0, 0.88, 0.285);
mesh.scale.set(1.8, 3, 1);
} else {
mesh.position.set(0, 0.205, 0.04);
mesh.rotation.x = -Math.PI / 2;
mesh.scale.set(1.75, 2.35, 1);
}
mesh.visible = false;
mesh.renderOrder = 17;
parent.add(mesh);
return { mesh, material, phase, baseScale: mesh.scale.clone() };
}
function updateTwinThermalVisual(model, timestamp) {
const smoothing = 0.075;
["ventilation", "heating"].forEach((feature) => {
const delta = model.thermalTargets[feature] - model.thermalLevels[feature];
model.thermalLevels[feature] += Math.abs(delta) < 0.002 ? delta : delta * smoothing;
});
const ventilation = model.thermalLevels.ventilation;
const heating = model.thermalLevels.heating;
const selectionFactor = model.isSelected ? 1 : 0.58;
const targetColor = model.thermalColors.base.clone();
targetColor.lerp(model.thermalColors.ventilation, ventilation * 0.5);
targetColor.lerp(model.thermalColors.heating, heating * 0.6);
model.insertMaterial.color.lerp(targetColor, 0.1);
const targetEmissive = model.thermalColors.ventilation.clone().multiplyScalar(
ventilation * 0.14,
);
targetEmissive.lerp(
model.thermalColors.heating,
Math.min(1, heating * 0.34),
);
model.insertMaterial.emissive.lerp(targetEmissive, 0.1);
model.insertMaterial.emissiveIntensity = 0.12 + Math.max(ventilation, heating) * 0.38;
const elapsed = timestamp * (0.0003 + ventilation * 0.00018);
model.ventFields.forEach((field, fieldIndex) => {
field.points.visible = ventilation > 0.015;
field.material.opacity = ventilation * selectionFactor * 0.92;
field.material.size = 0.045 + ventilation * 0.045;
for (let index = 0; index < field.positions.length / 3; index += 1) {
const progress = (elapsed + index / 12 + fieldIndex * 0.17) % 1;
const column = (index % 3) - 1;
const row = Math.floor(index / 3);
const offset = index * 3;
if (field.kind === "backrest") {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.27 + progress * 1.28;
field.positions[offset + 2] = 0.29 + Math.sin(progress * Math.PI) * 0.22;
} else {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.205 + progress * 0.36;
field.positions[offset + 2] = -0.36 + row * 0.24 + Math.sin(progress * Math.PI) * 0.05;
}
}
field.attribute.needsUpdate = true;
});
model.heatPulses.forEach((pulse) => {
const progress = (timestamp * 0.00042 + pulse.phase) % 1;
const scale = 0.78 + progress * 0.42;
pulse.mesh.visible = heating > 0.015;
pulse.mesh.scale.copy(pulse.baseScale).multiplyScalar(scale);
pulse.material.opacity = heating * selectionFactor * (1 - progress) * 0.38;
});
}
function createTwinSeat(THREE, x, accentColor) {
const root = new THREE.Group();
root.position.set(x, 0.14, 0.05);
const shell = new THREE.MeshPhysicalMaterial({
color: 0x344950,
roughness: 0.5,
metalness: 0.01,
clearcoat: 0.16,
clearcoatRoughness: 0.72,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x9fb1b2,
roughness: 0.68,
metalness: 0,
clearcoat: 0.08,
});
const thermalInsert = insert.clone();
const bolster = new THREE.MeshStandardMaterial({ color: 0x52686f, roughness: 0.72 });
const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.64, metalness: 0.32 });
const rail = new THREE.MeshStandardMaterial({ color: 0x65757a, roughness: 0.45, metalness: 0.7 });
const edge = new THREE.LineBasicMaterial({ color: 0x182529, transparent: true, opacity: 0.34 });
const seam = new THREE.LineBasicMaterial({ color: accentColor, transparent: true, opacity: 0.88 });
const trackedMaterials = [shell, insert, thermalInsert, bolster];
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structure, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structure, edge);
const slide = new THREE.Group();
root.add(slide);
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structure);
base.rotation.x = Math.PI / 2;
base.scale.set(1.42, 1, 0.55);
base.position.set(0, 0.34, -0.02);
base.castShadow = true;
base.receiveShadow = true;
slide.add(base);
const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06);
slide.add(cushionPivot);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE),
0.22,
[0, 0, 0.08],
[-Math.PI / 2, 0, 0],
shell,
edge,
);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE, 0.16),
0.055,
[0, 0.145, 0.03],
[-Math.PI / 2, 0, 0],
thermalInsert,
edge,
);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolster);
wing.rotation.x = Math.PI / 2;
wing.position.set(side * 0.61, 0.17, 0.08);
wing.castShadow = true;
cushionPivot.add(wing);
}
makeTwinSeam(THREE, cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]], seam);
makeTwinSeam(THREE, cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]], seam);
const cushionVentField = createTwinVentField(THREE, cushionPivot, "cushion");
const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5);
slide.add(backrestPivot);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE), 0.3, [0, 0, 0], [0, 0, 0], shell, edge);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE, 0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert, edge);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolster);
wing.position.set(side * 0.61, 0.78, 0.18);
wing.scale.set(1, 1, 0.72);
wing.castShadow = true;
backrestPivot.add(wing);
}
makeTwinSeam(THREE, backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]], seam);
const backrestVentField = createTwinVentField(THREE, backrestPivot, "backrest");
const heatPulses = [
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0),
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0.5),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.18),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.68),
];
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], rail, edge);
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], rail, edge);
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), shell);
headrest.scale.set(1.35, 1, 0.5);
headrest.position.set(0, 1.98, 0.01);
headrest.castShadow = true;
backrestPivot.add(headrest);
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), insert);
headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18);
headrestInsert.castShadow = true;
backrestPivot.add(headrestInsert);
const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7);
slide.add(legrestPivot);
const legrestExtension = new THREE.Group();
legrestPivot.add(legrestExtension);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.08),
0.18,
[0, 0, 0.36],
[-Math.PI / 2, 0, 0],
shell,
edge,
).scale.set(0.9, 0.72, 0.54);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.2),
0.045,
[0, 0.12, 0.4],
[-Math.PI / 2, 0, 0],
insert,
edge,
).scale.set(0.92, 0.74, 0.56);
const outerSide = Math.sign(x);
const anchors = {};
const addAnchor = (name, parent, position) => {
const anchor = new THREE.Object3D();
anchor.position.set(...position);
parent.add(anchor);
anchors[name] = anchor;
};
addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]);
addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]);
addAnchor("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.48,
occludedColor: 0xa7b8bb,
occludedLineOpacity: 0.1,
fillOpacity: 0.11,
thresholdAngle: 38,
renderOrder: 30,
},
glass: {
color: 0x527c85,
lineOpacity: 0.38,
occludedColor: 0xa5b8bb,
occludedLineOpacity: 0.06,
fillOpacity: 0.045,
thresholdAngle: 38,
renderOrder: 31,
},
detail: {
color: 0x536f76,
lineOpacity: 0.16,
occludedColor: 0xa8b7b9,
occludedLineOpacity: 0.035,
fillOpacity: 0.028,
thresholdAngle: 46,
renderOrder: 32,
},
wheel: {
color: 0x203d45,
lineOpacity: 0.52,
occludedColor: 0x9eafb2,
occludedLineOpacity: 0.14,
fillOpacity: 0.18,
thresholdAngle: 50,
renderOrder: 33,
},
interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, renderOrder: 32 },
};
function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) {
if (
!mesh.geometry?.attributes?.position
|| mesh.userData.twinOutline
|| style.lineOpacity <= 0
) return null;
const geometry = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle);
if (!geometry.getAttribute("position")?.count) {
geometry.dispose();
return null;
}
const visibleMaterial = new THREE.LineBasicMaterial({
color: style.color,
transparent: true,
opacity: style.lineOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.LessEqualDepth,
toneMapped: false,
});
visibleMaterial.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
"#include <project_vertex>",
"#include <project_vertex>\ngl_Position.z -= 0.0006 * gl_Position.w;",
);
};
visibleMaterial.customProgramCacheKey = () => "twin-visible-line-depth-bias-v1";
const visibleOutline = new THREE.LineSegments(geometry, visibleMaterial);
visibleOutline.name = `${mesh.name || "mesh"}_outline_visible`;
visibleOutline.renderOrder = style.renderOrder + 1;
visibleOutline.frustumCulled = mesh.frustumCulled;
mesh.add(visibleOutline);
mesh.userData.twinOutline = visibleOutline;
const occludedOpacity = style.occludedLineOpacity ?? 0;
if (occludedOpacity > 0) {
const occludedMaterial = new THREE.LineBasicMaterial({
color: style.occludedColor ?? style.color,
transparent: true,
opacity: occludedOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.GreaterDepth,
toneMapped: false,
});
const occludedOutline = new THREE.LineSegments(geometry, occludedMaterial);
occludedOutline.name = `${mesh.name || "mesh"}_outline_occluded`;
occludedOutline.renderOrder = style.renderOrder;
occludedOutline.frustumCulled = mesh.frustumCulled;
mesh.add(occludedOutline);
mesh.userData.twinOccludedOutline = occludedOutline;
}
return visibleOutline;
}
function applyTwinOutlineStyle(THREE, root, options = {}) {
const style = {
color: options.color ?? 0x52727a,
lineOpacity: options.lineOpacity ?? 0.58,
fillOpacity: options.fillOpacity ?? 0.014,
renderOrder: options.renderOrder ?? 40,
};
const meshes = [];
root.traverse((object) => {
if (object.isMesh && object.visible) meshes.push(object);
});
meshes.forEach((mesh) => {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.filter(Boolean).forEach((material) => {
material.transparent = true;
material.opacity = Math.min(Number(material.opacity ?? 1), style.fillOpacity);
material.depthWrite = false;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
});
mesh.castShadow = false;
mesh.receiveShadow = false;
mesh.renderOrder = style.renderOrder - 1;
addTwinMeshOutline(THREE, mesh, style, options.thresholdAngle ?? 28);
});
}
function configureTwinSolidEntity(THREE, roots, options = {}) {
const items = Array.isArray(roots) ? roots : [roots];
items.filter(Boolean).forEach((root) => {
root.traverse((object) => {
if (!object.isMesh || !object.visible) return;
if (object.userData.twinOutline) object.userData.twinOutline.visible = false;
if (object.userData.twinOccludedOutline) {
object.userData.twinOccludedOutline.visible = false;
}
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
material.transparent = Boolean(options.transparent);
material.opacity = options.opacity ?? 1;
material.depthWrite = options.depthWrite ?? !options.transparent;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
});
object.castShadow = true;
object.receiveShadow = true;
object.renderOrder = options.renderOrder ?? 46;
});
});
}
function configureActivePassengerSeatStyle(model) {
const {
shell,
insert,
thermalInsert,
bolster,
structure,
rail,
edge,
seam,
} = model.materials;
shell.color.setHex(0xd85b27);
shell.roughness = 0.3;
shell.clearcoat = 0.58;
shell.emissive.setHex(0x64210c);
shell.emissiveIntensity = 0.24;
insert.color.setHex(0xffe0a3);
insert.emissive.setHex(0x6d4315);
insert.emissiveIntensity = 0.2;
thermalInsert.color.setHex(0xffc866);
thermalInsert.emissive.setHex(0x7a3d0d);
thermalInsert.emissiveIntensity = 0.24;
bolster.color.setHex(0xb93426);
bolster.emissive.setHex(0x53120d);
bolster.emissiveIntensity = 0.2;
structure.color.setHex(0x39231d);
structure.emissive.setHex(0x1b0d09);
structure.emissiveIntensity = 0.14;
rail.color.setHex(0xa8bdc5);
rail.metalness = 0.82;
[shell, insert, thermalInsert, bolster, structure, rail].forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.depthTest = true;
material.alphaTest = 0;
material.colorWrite = true;
material.toneMapped = false;
if ("transmission" in material) material.transmission = 0;
material.needsUpdate = true;
});
edge.color.setHex(0xffd592);
edge.opacity = 1;
edge.depthTest = true;
edge.depthWrite = false;
seam.color.setHex(0xfff1cd);
seam.opacity = 1;
seam.depthTest = true;
seam.depthWrite = false;
model.thermalColors.base.setHex(0xffc866);
model.root.traverse((object) => {
if (!object.isLine && !object.isLineSegments) return;
object.renderOrder = 53;
});
}
function configureActiveSeatDepthLayers(THREE, model) {
const seatMeshes = [];
model.root.traverse((object) => {
if (object.isMesh && object.visible) seatMeshes.push(object);
});
seatMeshes.forEach((object) => {
object.renderOrder = 51;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const ghostMaterials = sourceMaterials.map((source) => new THREE.MeshBasicMaterial({
color: source?.color?.getHex?.() ?? 0xd98b55,
transparent: true,
opacity: 0.18,
depthWrite: false,
depthTest: true,
depthFunc: THREE.GreaterDepth,
side: THREE.DoubleSide,
toneMapped: false,
}));
const ghost = new THREE.Mesh(
object.geometry,
Array.isArray(object.material) ? ghostMaterials : ghostMaterials[0],
);
ghost.name = `${object.name || "seat"}_occluded_ghost`;
ghost.userData = { twinRole: "active_seat_occluded_ghost" };
ghost.renderOrder = 52;
ghost.castShadow = false;
ghost.receiveShadow = false;
object.add(ghost);
object.userData.twinOccludedGhost = ghost;
addTwinMeshOutline(THREE, object, {
color: 0xffd592,
lineOpacity: 0.54,
occludedColor: 0xd98b55,
occludedLineOpacity: 0.14,
fillOpacity: 0,
renderOrder: 52,
}, 28);
});
}
function addTwinGlassDepthMask(THREE, glassMesh) {
if (!glassMesh?.geometry || glassMesh.userData.twinDepthMask) return null;
const material = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
const mask = new THREE.Mesh(glassMesh.geometry, material);
mask.name = `${glassMesh.name || "glass"}_depth_mask`;
mask.userData = { twinRole: "glass_depth_mask" };
mask.renderOrder = 48;
mask.castShadow = false;
mask.receiveShadow = false;
glassMesh.add(mask);
glassMesh.userData.twinDepthMask = mask;
return mask;
}
function visibleTwinMeshBounds(THREE, root) {
const bounds = new THREE.Box3();
const meshBounds = new THREE.Box3();
root.updateWorldMatrix(true, true);
root.traverse((object) => {
if (!object.isMesh || !object.visible || !object.geometry?.attributes?.position) return;
if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
meshBounds.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld);
bounds.union(meshBounds);
});
return bounds;
}
function configureReferenceVehicleModel(THREE, vehicle) {
const materialCache = new Map();
const report = { meshes: 0, materials: new Map(), nodes: [] };
vehicle.traverse((object) => {
if (object.name) report.nodes.push(object.name);
if (!object.isMesh) return;
report.meshes += 1;
object.castShadow = false;
object.receiveShadow = false;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const configuredMaterials = sourceMaterials.map((sourceMaterial) => {
if (!sourceMaterial) return sourceMaterial;
if (materialCache.has(sourceMaterial)) return materialCache.get(sourceMaterial);
const material = sourceMaterial.clone();
const descriptor = referenceMaterialDescriptor(material);
const category = classifyReferenceMaterial(material);
const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail;
const key = `${category}:${material.name || "unnamed"}`;
report.materials.set(key, (report.materials.get(key) || 0) + 1);
[
"map",
"aoMap",
"bumpMap",
"normalMap",
"roughnessMap",
"metalnessMap",
"emissiveMap",
].forEach((textureKey) => {
if (textureKey in material) material[textureKey] = null;
});
if (material.emissive) {
material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
}
if (category === "glass") {
if (material.color) material.color.setHex(0x55727b);
material.transparent = true;
material.opacity = 0.3;
material.depthWrite = false;
material.side = THREE.DoubleSide;
object.castShadow = false;
object.renderOrder = 12;
} else if (category === "body") {
if (material.color) material.color.setHex(0x829398);
if ("shininess" in material) material.shininess = 72;
} else if (category === "light") {
material.map = null;
if (material.color) material.color.setHex(0x697b80);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
} else if (category === "interior" && material.color) {
material.color.setHex(0x6c7778);
} else if (/lines|\badd\b/.test(descriptor) && material.color) {
material.map = null;
material.color.setHex(0x4c5a5d);
} else if (/map[_ -]?[abc]/.test(descriptor) && material.color) {
if (/chrome|metal/.test(descriptor)) material.color.setHex(0xaeb9bb);
else if (/plastic/.test(descriptor)) material.color.setHex(0x303a3e);
else material.color.setHex(0x7f8e91);
} else if (/plastic|under|black/.test(descriptor) && material.color) {
material.color.setHex(0x303a3e);
} else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) {
material.color.setHex(0xaeb9bb);
}
material.transparent = true;
material.opacity = outlineStyle.fillOpacity;
material.depthWrite = ["body", "detail", "wheel"].includes(category);
material.depthTest = true;
material.side = category === "glass" ? THREE.DoubleSide : THREE.FrontSide;
material.wireframe = false;
if ("flatShading" in material) material.flatShading = false;
if ("roughness" in material) material.roughness = category === "body" ? 0.72 : 0.82;
if ("metalness" in material) material.metalness = category === "wheel" ? 0.48 : 0.08;
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.needsUpdate = true;
materialCache.set(sourceMaterial, material);
return material;
});
object.material = Array.isArray(object.material) ? configuredMaterials : configuredMaterials[0];
const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial);
if (categories.length && categories.every((category) => category === "shadow")) object.visible = false;
if (!object.visible) return;
const categoryPriority = ["body", "glass", "wheel", "light", "detail", "interior"];
const outlineCategory = categoryPriority.find((category) => categories.includes(category)) || "detail";
const outlineStyle = TWIN_OUTLINE_STYLE[outlineCategory];
object.renderOrder = outlineStyle.renderOrder - 1;
const isControllableWindowPane = String(object.userData.twinRole || "")
.startsWith("reference_window_");
if (!isControllableWindowPane) {
addTwinMeshOutline(
THREE,
object,
outlineStyle,
outlineStyle.thresholdAngle ?? 60,
);
}
});
return report;
}
function normalizeReferenceVehicle(THREE, vehicle) {
vehicle.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(vehicle);
let size = bounds.getSize(new THREE.Vector3());
if (size.x > size.z) {
vehicle.rotateY(Math.PI / 2);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
size = bounds.getSize(new THREE.Vector3());
}
const scale = 7.45 / Math.max(size.x, size.z);
vehicle.scale.multiplyScalar(scale);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
const center = bounds.getCenter(new THREE.Vector3());
vehicle.position.x -= center.x;
vehicle.position.z -= center.z;
vehicle.position.y += -0.145 - bounds.min.y;
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
return { bounds, size: bounds.getSize(new THREE.Vector3()), scale };
}
function 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 makeTriangleSubsetGeometry(THREE, source, vertexIndices) {
const subset = new THREE.BufferGeometry();
Object.entries(source.attributes).forEach(([name, attribute]) => {
const values = new attribute.array.constructor(vertexIndices.length * attribute.itemSize);
vertexIndices.forEach((sourceIndex, targetIndex) => {
for (let component = 0; component < attribute.itemSize; component += 1) {
values[targetIndex * attribute.itemSize + component] = (
attribute.array[sourceIndex * attribute.itemSize + component]
);
}
});
subset.setAttribute(
name,
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
);
});
subset.computeBoundingBox();
subset.computeBoundingSphere();
return subset;
}
function extractReferenceWindowPanes(THREE, vehicle) {
const panes = {
front_left: [],
front_right: [],
rear_left: [],
rear_right: [],
};
vehicle.updateMatrixWorld(true);
const sources = [];
vehicle.traverse((object) => {
if (object.isMesh && /^(window_gls|black_glasses)$/i.test(object.name || "")) {
sources.push(object);
}
});
sources.forEach((object) => {
const originalGeometry = object.geometry;
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
const positions = source.getAttribute("position");
const retainedVertices = [];
const paneVertices = Object.fromEntries(Object.keys(panes).map((key) => [key, []]));
const a = new THREE.Vector3();
const b = new THREE.Vector3();
const c = new THREE.Vector3();
const centroid = new THREE.Vector3();
const normal = new THREE.Vector3();
const edge = new THREE.Vector3();
const rearCandidates = [];
const pointKey = (point) => [point.x, point.y, point.z]
.map((value) => Math.round(value * 10000))
.join(":");
const edgeKey = (start, end) => [start, end].sort().join("|");
for (let index = 0; index < positions.count; index += 3) {
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
normal.copy(b).sub(a).cross(edge.copy(c).sub(a)).normalize();
const isSideWindow = (
Math.abs(normal.x) >= 0.48
&& Math.abs(normal.x) >= Math.abs(normal.z) * 1.2
&& Math.abs(centroid.x) >= 0.58
&& centroid.y >= 1.42
);
if (!isSideWindow) {
retainedVertices.push(index, index + 1, index + 2);
continue;
}
// The reference model uses positive X for the driver/vehicle-left side.
const side = centroid.x < 0 ? "right" : "left";
if (/^window_gls$/i.test(object.name || "")) {
paneVertices[`front_${side}`].push(index, index + 1, index + 2);
continue;
}
const vertexKeys = [pointKey(a), pointKey(b), pointKey(c)];
rearCandidates.push({
indices: [index, index + 1, index + 2],
side,
maxZ: Math.max(a.z, b.z, c.z),
edgeKeys: [
edgeKey(vertexKeys[0], vertexKeys[1]),
edgeKey(vertexKeys[1], vertexKeys[2]),
edgeKey(vertexKeys[2], vertexKeys[0]),
],
});
}
if (rearCandidates.length) {
const parents = rearCandidates.map((_, index) => index);
const find = (index) => {
let root = index;
while (parents[root] !== root) root = parents[root];
while (parents[index] !== index) {
const next = parents[index];
parents[index] = root;
index = next;
}
return root;
};
const unite = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
};
const edgeOwners = new Map();
rearCandidates.forEach((candidate, candidateIndex) => {
candidate.edgeKeys.forEach((key) => {
if (edgeOwners.has(key)) unite(candidateIndex, edgeOwners.get(key));
else edgeOwners.set(key, candidateIndex);
});
});
const components = new Map();
rearCandidates.forEach((candidate, candidateIndex) => {
const root = find(candidateIndex);
if (!components.has(root)) {
components.set(root, { side: candidate.side, maxZ: candidate.maxZ, candidates: [] });
}
const component = components.get(root);
component.maxZ = Math.max(component.maxZ, candidate.maxZ);
component.candidates.push(candidate);
});
const leadingMaxZ = { left: -Infinity, right: -Infinity };
components.forEach((component) => {
leadingMaxZ[component.side] = Math.max(leadingMaxZ[component.side], component.maxZ);
});
components.forEach((component) => {
const isRearDoorWindow = component.maxZ >= leadingMaxZ[component.side] - 0.38;
component.candidates.forEach((candidate) => {
if (isRearDoorWindow) paneVertices[`rear_${candidate.side}`].push(...candidate.indices);
else retainedVertices.push(...candidate.indices);
});
});
}
const retainedGeometry = makeTriangleSubsetGeometry(THREE, source, retainedVertices);
object.geometry = retainedGeometry;
Object.entries(paneVertices).forEach(([key, vertexIndices]) => {
if (!vertexIndices.length) return;
const pane = object.clone(false);
pane.name = `${object.name}_${key}`;
pane.geometry = makeTriangleSubsetGeometry(THREE, source, vertexIndices);
pane.userData = { ...object.userData, twinRole: `reference_window_${key}` };
object.parent.add(pane);
panes[key].push({ mesh: pane });
});
originalGeometry.dispose();
if (source !== originalGeometry) source.dispose();
});
vehicle.updateMatrixWorld(true);
Object.values(panes).flat().forEach((pane) => {
const closedPosition = pane.mesh.position.clone();
const openWorldPosition = pane.mesh.getWorldPosition(new THREE.Vector3());
openWorldPosition.y -= 0.86;
const openPosition = pane.mesh.parent.worldToLocal(openWorldPosition);
pane.closedPosition = closedPosition;
pane.openPosition = openPosition;
});
return panes;
}
function referenceFrontLampMaterialIndexes(mesh) {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
return materials.flatMap((material, index) => {
if (!material) return [];
const materialName = String(material.name || "").trim();
return /^fl[_ -]?light$/i.test(materialName) ? [index] : [];
});
}
function collectReferenceFrontLampMeshes(vehicle) {
const meshes = [];
vehicle.traverse((object) => {
if (!object.isMesh) return;
if (referenceFrontLampMaterialIndexes(object).length) meshes.push(object);
});
return meshes;
}
function emphasizeReferenceWindowPanes(THREE, panes) {
Object.values(panes).flat().forEach((pane) => {
const materials = Array.isArray(pane.mesh.material) ? pane.mesh.material : [pane.mesh.material];
const emphasizedMaterials = materials.filter(Boolean).map((source) => {
const material = source.clone();
if (material.color) material.color.setHex(0x258f89);
if (material.emissive) material.emissive.setHex(0x124c49);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.38;
material.transparent = true;
material.opacity = 0.6;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.toneMapped = false;
material.needsUpdate = true;
return material;
});
pane.mesh.material = Array.isArray(pane.mesh.material)
? emphasizedMaterials
: emphasizedMaterials[0];
pane.mesh.renderOrder = 58;
pane.materials = emphasizedMaterials;
pane.outline = addTwinMeshOutline(THREE, pane.mesh, {
color: 0x086861,
lineOpacity: 0.98,
fillOpacity: 0.6,
renderOrder: 59,
}, 22);
pane.depthMask = addTwinGlassDepthMask(THREE, pane.mesh);
});
}
function updateReferenceWindowOutlineLayers(systems, camera) {
if (!systems || !camera) return;
const nearSide = camera.position.x < 0 ? "right" : "left";
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
const isForegroundWindow = key.endsWith(`_${nearSide}`);
panes.forEach((pane) => {
if (!pane.outline?.material) return;
pane.outline.material.depthTest = !isForegroundWindow;
pane.outline.material.depthWrite = false;
pane.outline.renderOrder = isForegroundWindow ? 66 : 60;
});
});
}
function styleReferencePassiveGlass(THREE, vehicle) {
vehicle.traverse((object) => {
if (!object.isMesh) return;
if (String(object.userData.twinRole || "").startsWith("reference_window_")) return;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
if (!categories.includes("glass")) return;
const neutralMaterials = sourceMaterials.map((source) => {
if (!source) return source;
if (classifyReferenceMaterial(source) !== "glass") return source;
const material = source.clone();
if (material.color) material.color.setHex(0x809497);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.transparent = true;
material.opacity = 0.3;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
return material;
});
object.material = Array.isArray(object.material) ? neutralMaterials : neutralMaterials[0];
if (categories.every((category) => category === "glass")) {
object.renderOrder = 37;
if (object.userData.twinOutline?.material) {
object.userData.twinOutline.material.color.setHex(0x5d747a);
object.userData.twinOutline.material.opacity = 0.48;
}
}
});
}
function styleReferencePassiveRearExterior(THREE, vehicle) {
const rearLampMeshes = [];
const rearBumperMeshes = [];
const bounds = new THREE.Box3();
const sourceMeshes = [];
const bumperSourceNames = new Set([
"bodypaint_a",
"blk_part",
"black_matte",
"crom_part",
"uc001",
]);
vehicle.updateMatrixWorld(true);
vehicle.traverse((object) => {
if (object.isMesh && object.geometry?.attributes?.position) sourceMeshes.push(object);
});
sourceMeshes.forEach((object) => {
bounds.setFromObject(object);
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
const objectName = String(object.name || "").toLowerCase();
const isRearLight = (
bounds.max.z < -2.65
&& (
categories.includes("light")
|| /(?:brake|brk|red_light)/.test(objectName)
)
);
if (isRearLight) {
const isRedLens = /(?:brake|brk|red_light)/.test(objectName)
|| sourceMaterials.some((material) => /rear_emiss/i.test(material?.name || ""));
const styledMaterials = sourceMaterials.map((source) => {
if (!source) return source;
const material = source.clone();
if (material.color) material.color.setHex(isRedLens ? 0x8f666a : 0x657174);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.transparent = true;
material.opacity = isRedLens ? 0.4 : 0.2;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.toneMapped = false;
material.needsUpdate = true;
return material;
});
object.material = Array.isArray(object.material) ? styledMaterials : styledMaterials[0];
object.renderOrder = 39;
let outline = object.userData.twinOutline;
if (!outline) {
outline = addTwinMeshOutline(THREE, object, {
color: isRedLens ? 0x745459 : 0x53666a,
lineOpacity: isRedLens ? 0.44 : 0.4,
fillOpacity: isRedLens ? 0.4 : 0.2,
renderOrder: 40,
}, 34);
} else if (outline.material) {
outline.material.color.setHex(isRedLens ? 0x745459 : 0x53666a);
outline.material.opacity = isRedLens ? 0.44 : 0.4;
}
rearLampMeshes.push(object);
return;
}
if (!bumperSourceNames.has(objectName)) return;
const originalGeometry = object.geometry;
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
const positions = source.getAttribute("position");
const selectedVertices = [];
const a = new THREE.Vector3();
const b = new THREE.Vector3();
const c = new THREE.Vector3();
const centroid = new THREE.Vector3();
for (let index = 0; index < positions.count; index += 3) {
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
if (
centroid.z <= -3.28
&& centroid.y >= 0.18
&& centroid.y <= 1.42
) {
selectedVertices.push(index, index + 1, index + 2);
}
}
if (!selectedVertices.length) {
if (source !== originalGeometry) source.dispose();
return;
}
const isDarkTrim = /(?:blk|black|uc)/.test(objectName);
const isChrome = /crom/.test(objectName);
const material = new THREE.MeshBasicMaterial({
color: isDarkTrim ? 0x48575a : (isChrome ? 0x99a6a8 : 0x78898d),
transparent: true,
opacity: isDarkTrim ? 0.2 : 0.16,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
toneMapped: false,
});
const bumper = new THREE.Mesh(
makeTriangleSubsetGeometry(THREE, source, selectedVertices),
material,
);
bumper.name = `${object.name || "rear"}_passive_bumper`;
bumper.userData = { twinRole: "reference_passive_rear_bumper" };
bumper.renderOrder = 38;
bumper.castShadow = false;
bumper.receiveShadow = false;
object.add(bumper);
addTwinMeshOutline(THREE, bumper, {
color: isDarkTrim ? 0x3d5054 : 0x52696e,
lineOpacity: 0.42,
fillOpacity: isDarkTrim ? 0.2 : 0.16,
renderOrder: 39,
}, 34);
rearBumperMeshes.push(bumper);
if (source !== originalGeometry) source.dispose();
});
return { rearLampMeshes, rearBumperMeshes };
}
function emphasizeReferenceFrontLampMeshes(THREE, meshes) {
const materials = new Set();
meshes.forEach((mesh) => {
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
const emphasizedMaterials = sourceMaterials.map((source, index) => {
if (!source || !selectedIndexes.has(index)) return source;
const material = source.clone();
if (material.color) material.color.setHex(0x087b74);
if (material.emissive) material.emissive.setHex(0x16aaa1);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.95;
material.transparent = true;
material.opacity = 0.9;
material.depthWrite = false;
material.depthTest = true;
material.toneMapped = false;
material.needsUpdate = true;
materials.add(material);
return material;
});
mesh.material = Array.isArray(mesh.material) ? emphasizedMaterials : emphasizedMaterials[0];
mesh.renderOrder = 41;
});
return [...materials];
}
function createReferenceFrontLampHighlights(THREE, meshes) {
return meshes.flatMap((mesh, index) => {
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
const groups = source.groups.length
? source.groups
: [{ start: 0, count: source.getAttribute("position").count, materialIndex: 0 }];
const selectedVertices = [];
groups.forEach((group) => {
if (!selectedIndexes.has(group.materialIndex || 0)) return;
for (let vertexIndex = group.start; vertexIndex < group.start + group.count; vertexIndex += 1) {
selectedVertices.push(vertexIndex);
}
});
if (!selectedVertices.length) {
if (source !== mesh.geometry) source.dispose();
return [];
}
const selectedGeometry = makeTriangleSubsetGeometry(THREE, source, selectedVertices);
const material = new THREE.MeshBasicMaterial({
color: 0x087b74,
transparent: true,
opacity: 0.92,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false,
});
const highlight = mesh.clone(false);
highlight.name = `${mesh.name || "front_lamp"}_twin_highlight_${index}`;
highlight.geometry = selectedGeometry;
highlight.material = material;
highlight.userData = { twinRole: "reference_front_lamp_highlight" };
highlight.renderOrder = 46;
highlight.castShadow = false;
highlight.receiveShadow = false;
mesh.parent.add(highlight);
addTwinMeshOutline(THREE, highlight, {
color: 0x003f3b,
lineOpacity: 1,
fillOpacity: 0.92,
renderOrder: 47,
}, 8);
// The source lamp emitter is a very slim strip. Add a low-opacity copy that
// expands only across the lamp's own vertical envelope, so the headlamps
// remain attached to the original lens instead of looking like floating UI.
const haloGeometry = selectedGeometry.clone();
const haloPositions = haloGeometry.getAttribute("position");
if (!haloGeometry.boundingBox) haloGeometry.computeBoundingBox();
const center = haloGeometry.boundingBox.getCenter(new THREE.Vector3());
for (let vertexIndex = 0; vertexIndex < haloPositions.count; vertexIndex += 1) {
const x = haloPositions.getX(vertexIndex);
const y = haloPositions.getY(vertexIndex);
const z = haloPositions.getZ(vertexIndex);
haloPositions.setXYZ(
vertexIndex,
center.x + (x - center.x) * 1.035,
center.y + (y - center.y) * 2.25,
center.z + (z - center.z) * 1.035,
);
}
haloPositions.needsUpdate = true;
haloGeometry.computeBoundingBox();
haloGeometry.computeBoundingSphere();
const haloMaterial = new THREE.MeshBasicMaterial({
color: 0x49e1d6,
transparent: true,
opacity: 0.38,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
toneMapped: false,
});
const halo = mesh.clone(false);
halo.name = `${mesh.name || "front_lamp"}_twin_halo_${index}`;
halo.geometry = haloGeometry;
halo.material = haloMaterial;
halo.userData = { twinRole: "reference_front_lamp_halo" };
halo.renderOrder = 45;
halo.castShadow = false;
halo.receiveShadow = false;
mesh.parent.add(halo);
if (source !== mesh.geometry) source.dispose();
return [
{
mesh: highlight,
material,
idleOpacity: 0.92,
activeOpacity: 1,
isHalo: false,
},
{
mesh: halo,
material: haloMaterial,
idleOpacity: 0.38,
activeOpacity: 0.68,
isHalo: true,
},
];
});
}
function createReferenceFrontLampLensOutlines(THREE, vehicle) {
const lensOutlines = [];
vehicle.traverse((object) => {
if (!object.isMesh || !/^glass001$/i.test(object.name || "")) return;
const lens = object.clone(false);
lens.name = `${object.name}_twin_lens_contour`;
lens.material = new THREE.MeshBasicMaterial({
color: 0x1b8f88,
transparent: true,
opacity: 0.045,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
toneMapped: false,
});
lens.userData = { twinRole: "reference_front_lamp_lens_contour" };
lens.renderOrder = 43;
lens.castShadow = false;
lens.receiveShadow = false;
object.parent.add(lens);
const outline = addTwinMeshOutline(THREE, lens, {
color: 0x096d67,
lineOpacity: 0.96,
fillOpacity: 0.045,
renderOrder: 44,
}, 24);
if (outline) lensOutlines.push({ mesh: lens, outline });
});
return lensOutlines;
}
function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
return new Promise((resolve, reject) => {
const FBXLoaderClass = window.FBXLoader || THREE.FBXLoader;
if (!FBXLoaderClass) {
reject(new Error("FBXLoader is unavailable"));
return;
}
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 referenceWindowPanes = extractReferenceWindowPanes(THREE, vehicle);
const report = configureReferenceVehicleModel(THREE, vehicle);
styleReferencePassiveGlass(THREE, vehicle);
const passiveRearExterior = styleReferencePassiveRearExterior(THREE, vehicle);
const referenceFrontLampMeshes = collectReferenceFrontLampMeshes(vehicle);
emphasizeReferenceWindowPanes(THREE, referenceWindowPanes);
const referenceFrontLampMaterials = emphasizeReferenceFrontLampMeshes(
THREE,
referenceFrontLampMeshes,
);
const referenceFrontLampHighlights = createReferenceFrontLampHighlights(
THREE,
referenceFrontLampMeshes,
);
const referenceFrontLampLensOutlines = createReferenceFrontLampLensOutlines(
THREE,
vehicle,
);
vehicleSystems.referenceWindowPanes = referenceWindowPanes;
vehicleSystems.referenceFrontLampMaterials = referenceFrontLampMaterials;
vehicleSystems.referenceFrontLampMeshes = referenceFrontLampMeshes;
vehicleSystems.referenceFrontLampHighlights = referenceFrontLampHighlights;
vehicleSystems.referenceFrontLampLensOutlines = referenceFrontLampLensOutlines;
applyTwinVehicleTheme(vehicleSystems);
vehicle.name = "REFERENCE_VEHICLE_OUTLINE";
vehicle.userData.twinRole = "reference_vehicle";
vehicle.traverse((object) => object.layers.set(1));
scene.add(vehicle);
vehicleSystems.fallbackExteriorObjects.forEach((object) => {
object.visible = false;
});
vehicleSystems.fallbackInteriorObjects.forEach((object) => {
object.visible = false;
});
console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({
meshes: report.meshes,
size: normalized.size.toArray().map((value) => Number(value.toFixed(3))),
removedPassengerTriangles,
windowPanes: Object.fromEntries(
Object.entries(referenceWindowPanes).map(([key, value]) => [key, value.length]),
),
frontLampMaterials: referenceFrontLampMaterials.length,
frontLampMeshes: referenceFrontLampMeshes.length,
frontLampHighlights: referenceFrontLampHighlights.length,
frontLampLensOutlines: referenceFrontLampLensOutlines.length,
rearLampMeshes: passiveRearExterior.rearLampMeshes.length,
rearBumperMeshes: passiveRearExterior.rearBumperMeshes.length,
driverCushion,
}));
resolve({ vehicle, report, normalized, driverCushion });
},
(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 = '<i data-lucide="triangle-alert"></i><span>3D 引擎加载失败,精确状态仍可用</span>';
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.roofGlass, {
transparent: true,
opacity: 0.52,
depthWrite: false,
renderOrder: 58,
});
vehicleSystems.roofDepthMask = addTwinGlassDepthMask(THREE, vehicleSystems.roofGlass);
configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes.slice(1), {
transparent: true,
opacity: 0.88,
depthWrite: false,
renderOrder: 59,
});
configureTwinSolidEntity(THREE, vehicleSystems.roofShade, {
transparent: true,
opacity: 0.08,
renderOrder: 49,
});
configureTwinSolidEntity(THREE, vehicleSystems.headlampMeshes, {
transparent: false,
opacity: 1,
depthWrite: true,
renderOrder: 57,
});
const models = {
passenger: createTwinSeat(THREE, -1.05, 0x4a78af),
};
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: 51,
});
configureActiveSeatDepthLayers(THREE, models.passenger);
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 },
};
applyTwinVehicleTheme(vehicleSystems);
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
applyTwinVehicleTheme(vehicleSystems, event.detail?.role);
});
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
.then(({ vehicle, report, normalized, driverCushion }) => {
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 = '<i data-lucide="triangle-alert"></i><span>实车模型加载失败,已切换轻量回退模型</span>';
refreshIcons();
window.setTimeout(() => { fallback.hidden = true; }, 3200);
});
function resetView() {
Object.assign(state.seatTwin.view, {
yaw: -0.62,
pitch: 0.31,
distance: 10.2,
targetX: 0,
});
}
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);
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
positionTwinCallouts();
renderer.clear();
camera.layers.set(0);
renderer.render(scene, camera);
renderer.clearDepth();
const sceneBackground = scene.background;
scene.background = null;
camera.layers.set(1);
renderer.render(scene, camera);
scene.background = sceneBackground;
camera.layers.set(0);
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
window.requestAnimationFrame(animate);
}
setTwinSelection("passenger");
animate();
}
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
}
function isLocalDecisionHost() {
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
}
function renderHardwareAccess(status) {
state.hardwareAccess = status;
}
async function loadHardwareAccessStatus({ silent = false } = {}) {
try {
const query = new URLSearchParams({ client_id: state.hardwareClientId });
const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
if (response.status === 401) return;
const payload = await response.json();
if (!response.ok) throw new Error(payload.message || payload.error);
renderHardwareAccess(payload);
} catch (error) {
if (!silent) showToast(`使用权状态读取失败:${error.message}`);
}
}
function selectProvider(provider) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
button.classList.toggle("active", button.dataset.provider === provider);
});
}
function updateSeatFeedbackModeLabel() {
const label = $("#seatFeedbackModeLabel");
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_execution_allowed) 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);
const vehicleCanReady = Boolean(state.runtime?.vehicle_can?.configured);
$("#approvalField").hidden = !hardware || !approvalRequired;
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
? '<i data-lucide="radio-tower"></i>HIL · 真实 CAN'
: simulatedHardware
? '<i data-lucide="bug-play"></i>SIM · 模拟台架'
: '<i data-lucide="flask-conical"></i>DRY-RUN';
$("#safetyTitle").textContent = hardware
? "真实台架安全门禁"
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
$("#safetyText").textContent = hardware
? vehicleCanReady
? "同一 CAN 盒由单进程持有:通道 0 控制副驾座椅并校验板端反馈,通道 1 以 ISO CAN FD 500 kbit/s / 2 Mbit/s 控制空调等车身设备并校验状态帧。"
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
: 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) => `<option value="${vehicle.id}">${vehicle.name}</option>`).join("")
: '<option value="">未找到车型配置</option>';
const defaultVehicleId = state.runtime?.default_vehicle_id
|| state.runtime?.vehicle?.id
|| vehicles[0]?.id
|| "";
select.disabled = vehicles.length === 0;
if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true });
}
function modeProfiles() {
return Array.isArray(state.runtime?.modes) ? state.runtime.modes : [];
}
function modeChipLabel(mode) {
const name = String(mode?.name || mode?.id || "模式");
return name.endsWith("模式") && name.length > 2
? name.slice(0, -2)
: name;
}
function modeSecondsLabel(durationMs) {
const seconds = Number(durationMs) / 1000;
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
}
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}`;
const timeline = mode.timeline;
let cycleBadge = $(".mode-cycle-badge", button);
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
if (!cycleBadge) {
cycleBadge = document.createElement("small");
cycleBadge.className = "mode-cycle-badge";
button.append(cycleBadge);
}
const runtimeMinutes = Number(timeline.max_runtime_ms || 0) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
const cycleDurationMs = Number(timeline.cycle_duration_ms || 0);
const cycleLabel = cycleDurationMs > 0
? `${modeSecondsLabel(cycleDurationMs)}s循环`
: "目标到位循环";
cycleBadge.textContent = runtimeMinutes > 0
? `${cycleLabel} · 最长${runtimeLabel}min`
: cycleLabel;
button.dataset.modeBehavior = "cyclic";
} else if (cycleBadge) {
cycleBadge.remove();
}
const phaseSummary = Array.isArray(timeline?.phases)
? timeline.phases.map((phase) => {
if (Number(phase.duration_ms || 0) > 0) {
return `${phase.name} ${modeSecondsLabel(phase.duration_ms)}s`;
}
const timeout = Number(phase.timeout_ms || 0);
return timeout > 0
? `${phase.name} 目标到位(保护上限${modeSecondsLabel(timeout)}s)`
: `${phase.name} 目标到位`;
}).join(" → ")
: "";
button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n");
});
const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
const allButtons = buttons();
const dynamicButtons = dynamicModeOrder
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
const staticButtons = allButtons.filter((button) => !dynamicButtons.includes(button));
group.querySelector(".mode-group-divider")?.remove();
dynamicButtons.forEach((button) => group.append(button));
if (dynamicButtons.length && staticButtons.length) {
const divider = document.createElement("span");
divider.className = "mode-group-divider";
divider.setAttribute("aria-hidden", "true");
group.append(divider);
}
staticButtons.forEach((button) => group.append(button));
refreshIcons();
}
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 vehicleCan = state.runtime.vehicle_can || {};
const linkedHardwareReady = Boolean(hardware.configured && vehicleCan.configured);
const seatHardwareReady = Boolean(hardware.configured);
const hardwareExecutionReady = Boolean(state.runtime.hardware_execution_allowed);
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
if (state.runtime.openai_available) {
badge.classList.remove("danger");
badge.innerHTML = `<i data-lucide="sparkles"></i>${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 = '<i data-lucide="key-round"></i>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 = !hardwareExecutionReady;
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 ? "需授权令牌" : "调试免令牌";
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
? " · 动作防冲突"
: "";
const accessText = hardware.access_policy === "local_approval_idle_lease"
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
: "";
$("#hardwareNote").textContent = linkedHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · CH${vehicleCan.channel} 车身 CAN FD ${vehicleCan.arbitration.bitrate / 1000}k/${vehicleCan.data.bitrate / 1000000}M · ${profileText} · ${flashText} · ${approvalText}${accessText}${concurrencyText}`
: seatHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
: `联动真实执行已锁定:${profileText};请完成座椅固件、双通道 ZLG 适配器、车身 CAN FD${hardware.approval_required ? "和令牌" : ""}配置`;
$("#hardwareAliasNote").textContent = linkedHardwareReady
? "CH0 → 副驾座椅控制器 · CH1 → 车身 CAN FD"
: seatHardwareReady
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
: "尚未配置可执行的物理 CAN 通道";
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
? "未检测到匹配固件时,将在发送座椅控制帧前通过 IAR C-SPY 自动烧录并重新验证。"
: "仅接受周期反馈与四轴量程签名匹配的统一固件;需要自动烧录时请启用服务端开关。";
$("#hardwareBadge").className = `status-badge ${hardwareExecutionReady ? "" : "danger"}`.trim();
$("#hardwareBadge").innerHTML = linkedHardwareReady
? '<i data-lucide="shield-check"></i>双通道已配置'
: seatHardwareReady
? '<i data-lucide="shield-check"></i>座椅台架已配置'
: '<i data-lucide="shield-off"></i>硬件锁定';
$("#hardwareCoverage").textContent = linkedHardwareReady
? `CH0 座椅 + CH${vehicleCan.channel} 车身 · ${approvalText}${accessText}${concurrencyText}`
: seatHardwareReady
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
: "已阻断";
$("#hardwareCoverage").className = hardwareExecutionReady ? "available" : "blocked";
const modeAvailable = state.executionMode === "hardware"
? hardwareExecutionReady
: 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 || "",
hardware_client_id: state.hardwareClientId,
});
try {
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
cache: "no-store",
});
if (response.status === 401) {
window.location.replace("/login");
return false;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
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 = "<b>PASS</b><code>all</code><span>公共 Schema、领域 Schema 和语义约束均通过。</span>";
list.append(row);
return;
}
issues.forEach((issue) => {
const row = document.createElement("div");
row.className = `validation-item ${issue.severity}`;
const severity = document.createElement("b");
severity.textContent = issue.severity.toUpperCase();
const code = document.createElement("code");
code.textContent = `${issue.code} · ${issue.path}`;
const message = document.createElement("span");
message.textContent = issue.message;
row.append(severity, code, message);
list.append(row);
});
}
function resetSeatState() {
$$(".thermal-states > span").forEach((item) => {
item.classList.remove("on");
$("b", item).textContent = "--";
});
$$(".motion-axis").forEach((item) => {
$("b", item).textContent = "--";
$("i", item).style.width = "0%";
item.classList.remove("fault");
});
}
function mergeSeatActuatorState(previousState, patchState) {
const mergedState = { ...(previousState || {}) };
Object.entries(patchState || {}).forEach(([seat, functions]) => {
const previousSeat = mergedState[seat] || {};
const mergedSeat = { ...previousSeat };
Object.entries(functions || {}).forEach(([feature, value]) => {
if (feature === "position") {
const previousPosition = previousSeat.position || {};
const mergedPosition = { ...previousPosition };
Object.entries(value || {}).forEach(([axis, motion]) => {
mergedPosition[axis] = motion && typeof motion === "object"
? { ...(previousPosition[axis] || {}), ...motion }
: motion;
});
mergedSeat.position = mergedPosition;
return;
}
if (feature === "dynamic_mode") {
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
if (!item) return;
const running = value.state === "running";
item.classList.toggle("running", running);
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
$("b", item).textContent = running
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
: "未运行";
return;
}
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
? { ...(previousSeat[feature] || {}), ...value }
: value;
});
mergedState[seat] = mergedSeat;
});
return mergedState;
}
function renderSeatState(actuatorState) {
if (!actuatorState) return;
const mergedActuatorState = mergeSeatActuatorState(
state.seatActuatorState,
actuatorState,
);
state.seatActuatorState = mergedActuatorState;
resetSeatState();
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
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 outletModeLabel = (value) => {
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
if (["avoid_people", "avoid_person", "avoid_person_blow", "avoid_occupant"].includes(normalized)) {
return "避人吹";
}
if (["sweep", "sweeping", "oscillate", "oscillation"].includes(normalized)) {
return "扫风";
}
return "-";
};
const onOffLabel = (value) => {
if ([true, 1, "1", "on", "open", "opened", "active"].includes(value)) return "开启";
return "关闭";
};
const percentageValue = (value) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.min(100, numeric)) : 0;
};
const climate = domainState.climate;
if (climate) {
const active = climate.power === "on" || climate.ac === "on" || Number(climate.fan_level) > 0;
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}<sup>℃</sup>`;
$("#passengerTemperature").innerHTML = `${passengerTemperature}<sup>℃</sup>`;
$("#driverAirDirection").textContent = outletModeLabel(climate.zones?.driver?.outlet_mode);
$("#passengerAirDirection").textContent = outletModeLabel(climate.zones?.passenger?.outlet_mode);
$("#airflowVisual").dataset.level = String(Math.max(0, Math.min(7, Number(climate.fan_level) || 0)));
$("#fanLevel").textContent = `${climate.fan_level ?? 0} 档`;
$("#cycleMode").textContent = climate.cycle_mode === "internal" ? "内循环" : "外循环";
$("#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;
}
}
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}%`;
$("#shadePositionValue").textContent = `${shadePosition}%`;
$("#automaticDimming").textContent = roof.automatic_dimming === "on" ? "开启" : "关闭";
$("#roofState").textContent = roof.fault ? "FAULT" : "READY";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.roofTransmittance = transmittance;
vehicleSystems.targets.roofShadePosition = 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);
});
$("#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);
}
}
const windowState = domainState.window || domainState.windows;
if (windowState) {
const readWindowPosition = (key) => {
const source = windowState.positions?.[key]
?? windowState.windows?.[key]
?? windowState[key];
return percentageValue(source?.position_pct ?? source?.opening_pct ?? source?.position ?? source);
};
const windowPositions = {
frontLeftWindow: readWindowPosition("front_left"),
frontRightWindow: readWindowPosition("front_right"),
rearLeftWindow: readWindowPosition("rear_left"),
rearRightWindow: readWindowPosition("rear_right"),
};
Object.entries(windowPositions).forEach(([id, value]) => {
$(`#${id}`).textContent = `${value}%`;
});
const anyWindowOpen = Object.values(windowPositions).some((value) => value > 0);
$('[data-domain-panel="window"]').classList.toggle("active", anyWindowOpen);
$("#windowState").textContent = anyWindowOpen ? "OPEN" : "CLOSED";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
Object.entries(windowPositions).forEach(([id, value]) => {
const windowKey = {
frontLeftWindow: "front_left",
frontRightWindow: "front_right",
rearLeftWindow: "rear_left",
rearRightWindow: "rear_right",
}[id];
vehicleSystems.targets.windowPositions[windowKey] = value;
});
}
}
const frontLight = domainState.front_light
|| domainState.front_lights
|| domainState.exterior_light
|| domainState.headlight;
if (frontLight) {
const readLightState = (...keys) => {
const key = keys.find((candidate) => frontLight[candidate] !== undefined);
const source = key ? frontLight[key] : undefined;
return source?.state ?? source?.power ?? source;
};
const lightStates = {
lowBeamState: readLightState("low_beam", "dipped_beam"),
highBeamState: readLightState("high_beam", "main_beam"),
fogLightState: readLightState("fog_light", "fog"),
turnSignalState: readLightState("turn_signal", "indicator"),
};
Object.entries(lightStates).forEach(([id, value]) => {
$(`#${id}`).textContent = onOffLabel(value);
});
const anyLightOn = Object.values(lightStates).some((value) => onOffLabel(value) === "开启");
$('[data-domain-panel="front_light"]').classList.toggle("active", anyLightOn);
$("#frontLightState").textContent = anyLightOn ? "ON" : "OFF";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.lowBeam = onOffLabel(lightStates.lowBeamState) === "开启" ? 1 : 0;
vehicleSystems.targets.highBeam = onOffLabel(lightStates.highBeamState) === "开启" ? 1 : 0;
vehicleSystems.targets.fogLight = onOffLabel(lightStates.fogLightState) === "开启" ? 1 : 0;
vehicleSystems.targets.turnSignal = onOffLabel(lightStates.turnSignalState) === "开启" ? 1 : 0;
}
}
}
function renderedConfig(result) {
if (result.scene_config) {
return {
scene_config: result.scene_config,
function_configs: result.function_configs,
};
}
return result.preset_config || result.function_config;
}
function renderedPlan(result) {
const plans = result.command_plans || {};
return Object.keys(plans).length > 1 ? plans : result.command_plan;
}
function renderSavedPresets(result) {
const indicator = $("#presetIndicator");
const presets = result.saved_presets || [];
if (!presets.length) {
indicator.hidden = true;
return;
}
const latest = result.preset_config
? {
name: result.preset_config.metadata.name,
target: result.preset_config.metadata.labels.target,
}
: presets[presets.length - 1];
const targetName = latest.target === "driver" ? "主驾" : "副驾";
$("span", indicator).textContent = `已保存 ${targetName} · ${latest.name}`;
indicator.hidden = false;
}
const CONFIG_DOMAIN_META = {
seat: { label: "副驾座椅", icon: "armchair", className: "seat" },
climate: { label: "空调", icon: "fan", className: "climate" },
ambient_light: { label: "氛围灯", icon: "lamp-ceiling", className: "ambient" },
roof: { label: "智能天幕", icon: "panels-top-left", className: "roof" },
door: { label: "车门锁", icon: "door-open", className: "generic" },
window: { label: "车窗", icon: "panels-top-left", className: "generic" },
outlet: { label: "电动风口", icon: "wind", className: "climate" },
interior_light: { label: "车内灯", icon: "lightbulb", className: "ambient" },
};
const CONFIG_CAPABILITY_LABELS = {
"seat.set_ventilation": "设置座椅通风",
"seat.set_heating": "设置座椅加热",
"seat.set_longitudinal": "调节座椅前后位置",
"seat.set_longitudinal_position": "调节座椅前后位置",
"seat.set_cushion_tilt": "调节坐垫俯仰",
"seat.set_backrest_recline": "调节靠背角度",
"seat.set_legrest_height": "调节腿托角度",
"seat.set_legrest_extension": "调节腿托伸缩",
"seat.start_dynamic_mode": "启动动态座椅模式",
"seat.stop_dynamic_mode": "停止动态座椅模式",
"climate.set_temperature": "设置空调温度",
"climate.set_fan_level": "设置空调风量",
"climate.set_power": "设置空调电源",
"climate.set_ac": "设置 A/C",
"climate.set_cycle_mode": "设置内外循环",
"climate.set_outlet_mode": "设置出风模式",
"climate.set_front_defrost": "设置前风挡除霜",
"climate.set_control_mode": "设置空调控制模式",
"door.set_rear_lock": "设置后排门锁",
"window.set_motion": "控制车窗动作",
"window.set_position": "设置车窗开度",
"outlet.set_position_percent": "设置电动风口位置",
"outlet.set_calibrated_position": "设置电动风口标定位置",
"interior_light.set_indoor_power": "设置室内灯",
"interior_light.set_wall_power": "设置壁灯",
"interior_light.set_reading_power": "设置阅读灯",
"ambient_light.set_power": "设置氛围灯电源",
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
"ambient_light.set_color": "设置氛围灯颜色",
"ambient_light.set_brightness": "设置氛围灯亮度",
"ambient_light.set_mode": "设置氛围灯模式",
"roof.set_transmittance": "设置天幕透光率",
"roof.set_shade_position": "设置遮阳帘位置",
"roof.set_shade_motion": "控制遮阳帘动作",
"roof.set_automatic_dimming": "设置自动调光",
};
const CONFIG_PARAMETER_LABELS = {
power: "电源",
state: "状态",
level: "档位",
temperature_c: "目标温度",
fan_level: "目标风量",
position_pct: "目标位置",
angle_deg: "目标角度",
color_hex: "目标颜色",
brightness_pct: "目标亮度",
transmittance_pct: "目标透光率",
shade_position_pct: "遮阳帘位置",
automatic_dimming: "自动调光",
cycle_mode: "循环模式",
outlet_mode: "出风模式",
mode: "工作模式",
mode_id: "固件模式编号",
};
function makeConfigElement(tagName, className, textValue) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (textValue != null) element.textContent = textValue;
return element;
}
function configTargetLabel(target = {}, domain) {
const scopes = {
driver: "主驾",
passenger: "副驾",
front_left: "主驾",
front_right: "副驾",
front_row: "前排",
rear_row: "后排",
vehicle: "全车",
cabin: "全车舱",
};
const rawScope = target.instance || target.zone || (domain === "seat" ? "passenger" : "vehicle");
const scope = scopes[rawScope] || rawScope;
if (domain === "seat") return `${scope}座椅`;
const device = CONFIG_DOMAIN_META[domain]?.label || target.device || domain;
return `${scope} · ${device}`;
}
function formatConfigValue(name, value) {
const stateLabels = {
on: "开启",
off: "关闭",
internal: "内循环",
external: "外循环",
face: "面部出风",
feet: "脚部出风",
cinema: "观影",
rest: "小憩",
custom: "自定义",
};
if (typeof value === "string" && stateLabels[value]) return stateLabels[value];
if (name === "temperature_c") return `${value} ℃`;
if (name === "angle_deg") return `${value}°`;
if (name === "fan_level" || name === "level") return `${value} 档`;
if (name.endsWith("_pct")) return `${value}%`;
if (typeof value === "boolean") return value ? "开启" : "关闭";
if (value && typeof value === "object") return JSON.stringify(value);
return String(value ?? "--");
}
function configEntries(result) {
const configured = result.function_configs || {};
if (Object.keys(configured).length) return Object.entries(configured);
const single = result.function_config || result.preset_config;
const domain = single?.spec?.domain || "seat";
return single ? [[domain, single]] : [];
}
function planForDomain(result, domain) {
if (result.command_plans?.[domain]) return result.command_plans[domain];
return result.command_plan?.spec?.domain === domain ? result.command_plan : null;
}
function renderConfigCards(result) {
const container = $("#configCards");
container.replaceChildren();
const entries = configEntries(result);
const operations = entries.flatMap(([configKey, config]) => (
(config?.spec?.operations || []).map((operation) => ({
configKey,
domain: config?.spec?.domain || configKey,
config,
operation,
}))
));
if (!operations.length) {
const empty = makeConfigElement("div", "config-empty");
empty.innerHTML = '<i data-lucide="panel-top-dashed"></i><strong>暂无可展示的功能配置</strong><span>原始配置仍可在下方展开查看。</span>';
container.append(empty);
refreshIcons();
return 0;
}
const sceneSummary = makeConfigElement("div", "config-scene-summary");
const sceneCopy = makeConfigElement("div", "config-scene-copy");
const sceneIcon = makeConfigElement("span", "config-scene-icon");
sceneIcon.innerHTML = '<i data-lucide="workflow"></i>';
const sceneText = makeConfigElement("div", "");
sceneText.append(
makeConfigElement("strong", "", result.scene_config?.metadata?.name || "单域功能配置"),
makeConfigElement("span", "", result.scene_config ? "SceneConfig 联合场景" : "FunctionConfig 独立控制"),
);
sceneCopy.append(sceneIcon, sceneText);
const sceneStats = makeConfigElement("div", "config-scene-stats");
[[entries.length, "控制域"], [operations.length, "控制动作"], [result.validation?.length || 0, "校验提示"]].forEach(([value, label]) => {
const stat = makeConfigElement("span", "");
stat.append(makeConfigElement("b", "", value), makeConfigElement("small", "", label));
sceneStats.append(stat);
});
sceneSummary.append(sceneCopy, sceneStats);
container.append(sceneSummary);
operations.forEach(({ configKey, domain, config, operation }, index) => {
const meta = CONFIG_DOMAIN_META[domain] || { label: domain, icon: "box", className: "generic" };
const card = makeConfigElement("article", `config-card config-${meta.className}`);
const header = makeConfigElement("header", "config-card-header");
const identity = makeConfigElement("div", "config-card-identity");
const icon = makeConfigElement("span", "config-card-icon");
icon.innerHTML = `<i data-lucide="${meta.icon}"></i>`;
const title = makeConfigElement("div", "");
title.append(
makeConfigElement("small", "", `ACTION ${String(index + 1).padStart(2, "0")} · ${domain.toUpperCase()}`),
makeConfigElement("strong", "", CONFIG_CAPABILITY_LABELS[operation.capability] || operation.capability || meta.label),
);
identity.append(icon, title);
header.append(identity, makeConfigElement("span", "config-domain-badge", meta.label));
const objectBlock = makeConfigElement("section", "config-object-block");
objectBlock.append(
makeConfigElement("span", "config-block-label", "控制对象"),
makeConfigElement("strong", "config-object-name", configTargetLabel(operation.target, domain)),
makeConfigElement("code", "config-object-path", `${operation.target?.device || domain} / ${operation.target?.zone || operation.target?.instance || "vehicle"}`),
);
const parametersBlock = makeConfigElement("section", "config-parameters-block");
parametersBlock.append(makeConfigElement("span", "config-block-label", "目标参数"));
const parameterGrid = makeConfigElement("div", "config-parameter-grid");
const parameters = Object.entries(operation.parameters || {});
(parameters.length ? parameters : [["state", "--"]]).forEach(([name, value]) => {
const item = makeConfigElement("div", "config-parameter");
const valueNode = makeConfigElement("strong", "", formatConfigValue(name, value));
if (name === "color_hex" && /^#[0-9A-Fa-f]{6}$/.test(String(value))) {
const swatch = makeConfigElement("i", "config-color-swatch");
swatch.style.background = value;
valueNode.prepend(swatch);
}
item.append(
makeConfigElement("span", "", CONFIG_PARAMETER_LABELS[name] || name),
valueNode,
);
parameterGrid.append(item);
});
parametersBlock.append(parameterGrid);
const plan = planForDomain(result, configKey);
const allCommands = plan?.spec?.commands || [];
const matchedCommands = allCommands.filter((command) => !command.operation_ref || command.operation_ref === operation.id);
const command = matchedCommands[0] || null;
const hardwareBlock = makeConfigElement("section", `config-hardware-block ${command ? "mapped" : "unmapped"}`);
hardwareBlock.append(makeConfigElement("span", "config-block-label", "硬件映射"));
const route = makeConfigElement("div", "config-hardware-route");
const from = makeConfigElement("div", "config-route-node");
from.append(makeConfigElement("small", "", "逻辑配置"), makeConfigElement("strong", "", operation.id || "operation"));
const arrow = makeConfigElement("i", "config-route-arrow");
arrow.innerHTML = '<i data-lucide="arrow-right"></i>';
const to = makeConfigElement("div", "config-route-node target");
to.append(
makeConfigElement("small", "", command?.bus || plan?.spec?.platform || "待编译"),
makeConfigElement("strong", "", command?.message || "暂无 CAN 命令"),
);
route.append(from, arrow, to);
hardwareBlock.append(route);
const signals = Object.keys(command?.signals || {});
const signalList = makeConfigElement("div", "config-signal-list");
if (signals.length) {
signals.slice(0, 5).forEach((signal) => signalList.append(makeConfigElement("code", "", signal)));
if (signals.length > 5) signalList.append(makeConfigElement("code", "config-signal-more", `+${signals.length - 5}`));
} else {
signalList.append(makeConfigElement("span", "config-unmapped-note", "虚拟状态可预览,等待硬件编译映射"));
}
hardwareBlock.append(signalList);
const footer = makeConfigElement("footer", "config-card-footer");
const executionScope = config.metadata?.labels?.execution_scope;
const executionScopeLabel = result.execution_mode === "hardware" && domain === "seat"
? "座椅 CAN · CH0"
: executionScope === "vehicle_can_channel_1"
? "车身 CAN · CH1"
: executionScope === "virtual_only" ? "仅虚拟执行" : "已通过配置校验";
footer.append(
makeConfigElement("span", "", `超时 ${operation.timeout_ms ?? "--"} ms`),
makeConfigElement("span", "", operation.failure_policy === "continue" ? "失败后继续" : "失败即中止"),
makeConfigElement("span", "", executionScopeLabel),
);
card.append(header, objectBlock, parametersBlock, hardwareBlock, footer);
container.append(card);
});
refreshIcons();
return operations.length;
}
function renderCode(result) {
const config = renderedConfig(result);
$("#configCode code").textContent = toYaml(config);
const configCardCount = renderConfigCards(result);
const configDomains = Object.keys(result.function_configs || {});
$("#configFileName").textContent = result.scene_config
? `scene.yaml + functions/{${configDomains.join(",")}}.yaml`
: result.preset_config
? "presets/seat.yaml"
: `functions/${configDomains[0] || "seat"}.yaml`;
$("#configNavMeta").textContent = `${configCardCount} 个控制动作`;
$("#planCode code").textContent = toYaml(renderedPlan(result));
const planDomains = Object.keys(result.command_plans || {});
$("#planFileName").textContent = planDomains.length > 1
? `command-plans/{${planDomains.join(",")}}.yaml`
: `command-plans/${planDomains[0] || "seat"}.yaml`;
const resultItems = result.execution_report?.spec?.results || [];
const telemetry = resultItems.find((item) => item.telemetry?.feedback_frame)?.telemetry
|| resultItems[0]?.telemetry;
const frames = resultItems
.map((item) => item.telemetry?.command_frame)
.filter(Boolean);
const summary = $("#frameSummary");
if (frames.length) {
const feedback = telemetry?.feedback_frame
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
: "NO DIRECT FEEDBACK";
const simulatedHil = result.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
const mode = simulatedHil
? "HIL · SIMULATED"
: result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN";
summary.innerHTML = `<span>Frames</span><b>${frames.length}</b><span>First payload</span><code>${frames[0].frame_id} · ${frames[0].data_hex}</code><span>Feedback</span><code>${feedback}</code><span>Mode</span><b>${mode}</b>`;
$("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`;
} else if (telemetry?.operation === "save_posture_preset") {
summary.innerHTML = `<span>Operation</span><b>PRESET STORE</b><span>CAN</span><code>NO FRAME EMITTED</code><span>Scope</span><b>CONVERSATION</b>`;
$("#planNavMeta").textContent = "预设存储 · 无 CAN 帧";
} else {
summary.innerHTML = "<span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>NOT EXECUTED</b>";
$("#planNavMeta").textContent = "未生成 CAN 帧";
}
}
function renderExecution(result) {
const summary = $("#executionSummary");
const list = $("#resultList");
list.replaceChildren();
const report = result.execution_report;
if (!report) {
summary.className = "execution-summary failed";
const compileIssue = result.validation?.find((issue) => issue.code === "CAN_COMPILE_REJECTED");
const blockedReason = {
needs_confirmation: "请求需要补充确认",
capability_unavailable: "目标能力已识别,但对应域尚未接入执行器",
compile_failed: compileIssue?.message || "CAN 命令编译被安全约束阻止",
}[result.status] || "配置或语义校验未通过";
summary.innerHTML = `<i data-lucide="circle-x"></i><div><strong>执行未开始</strong><span>${blockedReason}</span></div>`;
$("#resultNavMeta").textContent = "未执行 · 查看原因";
$("#resultPanelState").textContent = "BLOCKED";
$("#resultPanelState").className = "workspace-panel-state failed";
refreshIcons();
return;
}
const reportSucceeded = report.spec.status === "succeeded";
const partiallySucceeded = result.status === "partially_succeeded";
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
const presetSave = Boolean(result.preset_config);
const modeSave = Boolean(result.mode_profile);
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
const physical = report.spec.mode === "hil" && !simulatedHil;
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
const failedOperations = operationResults.filter((item) => item.status === "failed");
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
let executionDetail = modeSave
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
: presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: simulatedHil
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
if (partiallySucceeded) {
executionDetail = `部分执行完成:${succeededDomains.join(" / ")} 成功 ${succeededOperations.length} 项;${failedDomains.join(" / ")} 未完成 ${failedOperations.length} 项,失败设备未阻断其他设备`;
}
if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`;
if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件";
else if (firmwareResult) executionDetail += ";板上统一固件协议签名已校验";
if (result.deferred_requests?.length) {
executionDetail += ";未接入域请求已保留为 deferred request";
}
if (!reportSucceeded && !partiallySucceeded) {
const failure = report.spec.results.find((item) => item.status === "failed");
const failureTelemetry = failure?.telemetry || {};
executionDetail = simulatedHil
? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。`
: failureTelemetry.error_type === "CanGatewayError"
? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}`
: failureTelemetry.error_type === "FirmwareProgrammingError"
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
: failureTelemetry.calibration_required
? `大幅相对移动已转换为绝对目标,但 Motor${failureTelemetry.motor_selector} 的 Position_Valid=0;未发送绝对移动命令,请先在台架清空并备好急停后执行显式端点标定。`
: failureTelemetry.failure_category === "stall_no_hall_activity"
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},未检测到持续 Hall 活动;可能已到目标方向机械端点/存在阻挡,或 Hall 供电、信号线未接通。请先安全地反向移动 2° 诊断。`
: failureTelemetry.failure_category === "stall_after_hall_activity"
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},运动后发生堵转,请检查目标方向端点或机械阻挡。`
: failureTelemetry.motor_result_name
? `Motor${failureTelemetry.motor_selector} ${failureTelemetry.motor_result_name},板端事件 ${failureTelemetry.last_motor_event_name || "UNAVAILABLE"};${failureTelemetry.stop_all_attempted ? "已尝试发送 STOP_ALL。" : "请检查硬件回读。"}`
: failureTelemetry.stop_all_attempted
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
: failure?.message || "真实台架未返回满足目标的物理反馈。";
}
const executionTitle = reportSucceeded
? modeSave ? "长期模式已保存"
: presetSave ? "姿态预设已保存"
: simulatedHil ? "模拟台架执行成功"
: physical ? "真实台架执行成功" : "虚拟执行成功"
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
const executionIcon = reportSucceeded
? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big")
: partiallySucceeded ? "circle-alert" : "circle-x";
summary.innerHTML = `<i data-lucide="${executionIcon}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
: "执行失败 · 查看回读";
$("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : partiallySucceeded ? "PARTIAL" : "FAILED";
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
report.spec.results.forEach((item) => {
const row = document.createElement("div");
row.className = "result-item";
row.innerHTML = `<i data-lucide="${item.status === "succeeded" ? "circle-check" : "circle-x"}"></i>`;
const text = document.createElement("div");
const title = document.createElement("strong");
title.textContent = item.ref;
const detail = document.createElement("span");
detail.textContent = item.message;
text.append(title, detail);
const frame = document.createElement("code");
const firmwareTelemetry = item.telemetry?.firmware_verified
? [
item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED",
item.telemetry.firmware_revision == null ? null : `REV ${item.telemetry.firmware_revision}`,
item.telemetry.identity_frame_id && item.telemetry.identity_data_hex
? `${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
: item.telemetry.protocol_signature,
].filter(Boolean).join(" · ")
: null;
frame.textContent = item.telemetry?.command_frame
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
: firmwareTelemetry
? firmwareTelemetry
: "CONTROL PLANE · NO CAN";
row.append(text, frame);
list.append(row);
});
refreshIcons();
}
function 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 partiallySucceeded = result.status === "partially_succeeded";
const executionSucceeded = result.status === "succeeded" || partiallySucceeded;
const domains = Object.keys(result.function_configs || {});
const domainLabels = {
seat: "座椅",
climate: "空调",
roof: "天幕",
ambient_light: "氛围灯",
};
$("#resultTitle").textContent = partiallySucceeded
? `${domains.length} 域场景部分完成`
: 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(
partiallySucceeded ? "partial" : 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;
}
if (state.executionMode === "hardware"
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
showToast(state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权");
return;
}
const button = $("#runButton");
button.disabled = true;
button.classList.add("loading");
button.innerHTML = '<i data-lucide="loader-circle"></i><span>处理中</span>';
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 === "hardware"
? { hardware_client_id: state.hardwareClientId }
: {}),
...(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) {
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
requestError.code = payload.error;
requestError.retryAfter = Number(
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
);
throw requestError;
}
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
? (payload.status === "partially_succeeded"
? "已执行可用设备;部分设备未完成,详见执行结果"
: simulated
? "模拟台架执行完成"
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
} catch (error) {
window.clearInterval(state.progressTimer);
resetPipeline();
if (error.code === "hardware_access_required") {
setRunStatus("failed", "ACCESS REQUIRED");
$("#resultTitle").textContent = "真实台架使用权无效";
await loadHardwareAccessStatus({ silent: true });
showToast(error.message);
} else if (error.code === "hardware_busy") {
setRunStatus("failed", "BENCH BUSY");
$("#resultTitle").textContent = "真实台架正在使用";
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
showToast(`${error.message}${retryHint}`);
} else {
setRunStatus("failed", "FAILED");
$("#resultTitle").textContent = "请求处理失败";
showToast(error.message);
}
} finally {
button.disabled = false;
button.classList.remove("loading");
const hardwareLabel = state.runtime?.hardware?.approval_required
? "授权并真实执行"
: "真实执行";
const buttonLabel = state.executionMode === "hardware"
? hardwareLabel
: state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行";
button.innerHTML = `<i data-lucide="play"></i><span>${buttonLabel}</span>`;
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 loadHardwareAccessStatus({ silent: true });
state.hardwareAccessPoller = window.setInterval(
() => loadHardwareAccessStatus({ silent: true }),
3000,
);
await synchronizePhysicalSeatState();
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
}
});