Files
Cognitive_Agent/apps/web-console/app.js
T
2026-07-30 08:45:38 +08:00

3369 lines
127 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;
}
const state = {
provider: "openai",
executionMode: "dry_run",
conversationId: loadConversationId(),
runtime: null,
result: null,
progressTimer: null,
seatTwin: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
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, 140],
legrest_height: [95, 160],
};
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);
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 = 2 + position("cushion_tilt", 50) * 0.09;
return {
cushion_tilt: cushion,
backrest_recline: included("backrest_recline", 20) - 90 + cushion,
legrest_height: Math.abs(
180 - cushion - included("legrest_height", 0),
),
};
}
function makeDynamicLine(THREE, parent, pointCount, material, segments = false) {
const positions = new Float32Array(pointCount * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const line = segments
? new THREE.LineSegments(geometry, material)
: new THREE.Line(geometry, material);
line.frustumCulled = false;
line.renderOrder = 20;
parent.add(line);
return { line, positions, attribute: geometry.getAttribute("position") };
}
function createTwinAngleGuide(THREE, parent, color) {
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.95,
depthTest: false,
});
const group = new THREE.Group();
parent.add(group);
const dotMaterial = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.96,
depthTest: false,
});
const dotGeometry = new THREE.SphereGeometry(0.022, 8, 8);
const dots = Array.from({ length: 13 }, () => {
const dot = new THREE.Mesh(dotGeometry, dotMaterial);
dot.renderOrder = 21;
group.add(dot);
return dot;
});
dots[0].scale.setScalar(1.6);
dots[dots.length - 1].scale.setScalar(1.6);
return {
group,
arc: makeDynamicLine(THREE, group, 49, material),
rays: makeDynamicLine(THREE, group, 4, material, true),
dots,
};
}
function updateTwinAngleGuide(guide, x, pivotY, pivotZ, startDeg, sweepDeg, radius) {
for (let index = 0; index < 49; index += 1) {
const angle = (startDeg + sweepDeg * index / 48) * Math.PI / 180;
const offset = index * 3;
guide.arc.positions[offset] = x;
guide.arc.positions[offset + 1] = pivotY + Math.sin(angle) * radius;
guide.arc.positions[offset + 2] = pivotZ + Math.cos(angle) * radius;
}
const endpoints = [
[x, pivotY, pivotZ],
[guide.arc.positions[0], guide.arc.positions[1], guide.arc.positions[2]],
[x, pivotY, pivotZ],
[
guide.arc.positions[guide.arc.positions.length - 3],
guide.arc.positions[guide.arc.positions.length - 2],
guide.arc.positions[guide.arc.positions.length - 1],
],
];
endpoints.flat().forEach((value, index) => {
guide.rays.positions[index] = value;
});
guide.dots.forEach((dot, index) => {
const source = index * 4 * 3;
dot.position.set(
guide.arc.positions[source],
guide.arc.positions[source + 1],
guide.arc.positions[source + 2],
);
});
guide.arc.attribute.needsUpdate = true;
guide.rays.attribute.needsUpdate = true;
}
function makeTwinBox(THREE, parent, size, position, material, edgeMaterial) {
const geometry = new THREE.BoxGeometry(...size);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 32), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeCabinBeam(THREE, parent, start, end, radius, material) {
const from = new THREE.Vector3(...start);
const to = new THREE.Vector3(...end);
const direction = to.clone().sub(from);
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, direction.length(), 12),
material,
);
beam.position.copy(from).add(to).multiplyScalar(0.5);
beam.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.normalize(),
);
beam.castShadow = true;
parent.add(beam);
return beam;
}
function createTwinAirflowField(THREE, parent) {
const count = 56;
const positions = new Float32Array(count * 3);
const seeds = Array.from({ length: count }, (_, index) => (
(index * 0.61803398875) % 1
));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x55bdec,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 24;
parent.add(points);
return {
points,
positions,
seeds,
attribute: geometry.getAttribute("position"),
material,
level: 0,
};
}
function makeVehicleProfileShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-3.72, 0.2);
shape.lineTo(-3.72, 0.92);
shape.quadraticCurveTo(-3.6, 1.28, -3.12, 1.47);
shape.quadraticCurveTo(-2.42, 1.51, -1.52, 1.45);
shape.lineTo(1.86, 1.45);
shape.quadraticCurveTo(2.06, 1.47, 2.22, 1.52);
shape.quadraticCurveTo(2.88, 1.52, 3.48, 1.28);
shape.quadraticCurveTo(3.76, 1.12, 3.74, 0.72);
shape.lineTo(3.68, 0.24);
shape.lineTo(3.12, 0.18);
shape.bezierCurveTo(3.02, 0.76, 2.8, 1.3, 2.35, 1.3);
shape.bezierCurveTo(1.9, 1.3, 1.68, 0.76, 1.58, 0.18);
shape.lineTo(-1.48, 0.18);
shape.bezierCurveTo(-1.58, 0.76, -1.8, 1.3, -2.25, 1.3);
shape.bezierCurveTo(-2.7, 1.3, -2.92, 0.76, -3.02, 0.18);
shape.lineTo(-3.72, 0.2);
shape.closePath();
return shape;
}
function makeVehicleGreenhouseShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.62, 1.31);
shape.quadraticCurveTo(-2.28, 1.8, -1.82, 2.42);
shape.quadraticCurveTo(-0.48, 2.68, 0.92, 2.5);
shape.quadraticCurveTo(1.58, 2.4, 2.18, 1.31);
shape.closePath();
return shape;
}
function makeVehicleSideGlassShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.58, 1.32);
shape.lineTo(-1.79, 2.36);
shape.quadraticCurveTo(-0.5, 2.56, 1.03, 2.42);
shape.lineTo(1.82, 1.32);
shape.closePath();
return shape;
}
function makeRoundedPanelShape(THREE, width, length, radius) {
const shape = new THREE.Shape();
const halfWidth = width / 2;
const halfLength = length / 2;
shape.moveTo(-halfWidth + radius, -halfLength);
shape.lineTo(halfWidth - radius, -halfLength);
shape.quadraticCurveTo(halfWidth, -halfLength, halfWidth, -halfLength + radius);
shape.lineTo(halfWidth, halfLength - radius);
shape.quadraticCurveTo(halfWidth, halfLength, halfWidth - radius, halfLength);
shape.lineTo(-halfWidth + radius, halfLength);
shape.quadraticCurveTo(-halfWidth, halfLength, -halfWidth, halfLength - radius);
shape.lineTo(-halfWidth, -halfLength + radius);
shape.quadraticCurveTo(-halfWidth, -halfLength, -halfWidth + radius, -halfLength);
shape.closePath();
return shape;
}
function createTwinRearBench(THREE) {
const root = new THREE.Group();
root.position.set(0, 0.12, -1.72);
root.userData.twinRole = "fixed_rear_bench";
const leather = new THREE.MeshPhysicalMaterial({
color: 0x35484e,
roughness: 0.58,
metalness: 0.01,
clearcoat: 0.12,
clearcoatRoughness: 0.75,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x829497,
roughness: 0.72,
clearcoat: 0.05,
});
const structure = new THREE.MeshStandardMaterial({
color: 0x26343a,
roughness: 0.68,
metalness: 0.25,
});
const buckleMaterial = new THREE.MeshStandardMaterial({
color: 0xb64b43,
roughness: 0.5,
});
const edge = new THREE.LineBasicMaterial({
color: 0x19272b,
transparent: true,
opacity: 0.42,
});
makeTwinBox(THREE, root, [2.5, 0.18, 0.72], [0, 0.24, 0.08], structure, edge);
const cushion = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 1.82, 7, 24), leather);
cushion.rotation.z = Math.PI / 2;
cushion.scale.set(0.92, 1, 1.23);
cushion.position.set(0, 0.46, 0.08);
cushion.castShadow = true;
cushion.receiveShadow = true;
root.add(cushion);
const backrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 1.78, 7, 24), leather);
backrest.rotation.z = Math.PI / 2;
backrest.scale.set(1.46, 1, 0.43);
backrest.position.set(0, 1.04, -0.68);
backrest.castShadow = true;
root.add(backrest);
[-0.79, 0, 0.79].forEach((x) => {
const cushionInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.13, 0.42, 6, 18),
insert,
);
cushionInsert.rotation.z = Math.PI / 2;
cushionInsert.scale.set(0.52, 1, 2.05);
cushionInsert.position.set(x, 0.71, 0.08);
cushionInsert.castShadow = true;
root.add(cushionInsert);
const backInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.42, 6, 18),
insert,
);
backInsert.scale.set(1.65, 1, 0.42);
backInsert.position.set(x, 1.05, -0.535);
backInsert.castShadow = true;
root.add(backInsert);
makeCabinBeam(THREE, root, [x - 0.11, 1.43, -0.7], [x - 0.11, 1.55, -0.7], 0.018, structure);
makeCabinBeam(THREE, root, [x + 0.11, 1.43, -0.7], [x + 0.11, 1.55, -0.7], 0.018, structure);
const headrest = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.16, 7, 18),
leather,
);
headrest.scale.set(1.25, 1, 0.55);
headrest.position.set(x, 1.62, -0.7);
headrest.castShadow = true;
root.add(headrest);
});
[-0.4, 0.4].forEach((x) => {
makeTwinBox(THREE, root, [0.09, 0.075, 0.12], [x, 0.73, -0.04], buckleMaterial, edge);
});
return root;
}
function createTwinCabin(THREE, scene) {
const cabin = new THREE.Group();
const trim = new THREE.MeshStandardMaterial({
color: 0x25343a,
roughness: 0.6,
metalness: 0.2,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
const softTrim = new THREE.MeshStandardMaterial({
color: 0x506167,
roughness: 0.8,
transparent: true,
opacity: 0.5,
depthWrite: false,
});
const cockpit = new THREE.MeshStandardMaterial({
color: 0x243238,
roughness: 0.6,
metalness: 0.18,
});
const consoleTop = new THREE.MeshStandardMaterial({
color: 0x5c6c70,
roughness: 0.66,
metalness: 0.12,
});
const accent = new THREE.MeshStandardMaterial({
color: 0x26364c,
emissive: 0x78efff,
emissiveIntensity: 0.18,
roughness: 0.22,
metalness: 0.18,
depthTest: false,
});
const ambientHousingMaterial = new THREE.MeshStandardMaterial({
color: 0x17242c,
roughness: 0.38,
metalness: 0.56,
emissive: 0x08141a,
emissiveIntensity: 0.18,
});
const ambientMeshes = [];
const invisibleEdge = new THREE.LineBasicMaterial({ transparent: true, opacity: 0 });
const shellMaterial = new THREE.MeshPhysicalMaterial({
color: 0x84adb7,
roughness: 0.18,
metalness: 0.1,
transmission: 0.48,
transparent: true,
opacity: 0.17,
depthWrite: false,
side: THREE.DoubleSide,
clearcoat: 0.9,
clearcoatRoughness: 0.18,
});
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0x6f9eae,
roughness: 0.06,
transmission: 0.86,
transparent: true,
opacity: 0.22,
depthWrite: false,
side: THREE.DoubleSide,
});
const shellEdge = new THREE.LineBasicMaterial({
color: 0x456975,
transparent: true,
opacity: 0.56,
});
const roofEntityEdge = new THREE.LineBasicMaterial({
color: 0xbfd5ff,
transparent: true,
opacity: 1,
depthTest: false,
});
const ventEntityEdge = new THREE.LineBasicMaterial({
color: 0x9eeaff,
transparent: true,
opacity: 1,
depthTest: false,
});
const bodyShell = makeTwinPanel(
THREE,
cabin,
makeVehicleProfileShape(THREE),
3.16,
[0, 0, 0],
[0, -Math.PI / 2, 0],
shellMaterial,
shellEdge,
);
bodyShell.castShadow = false;
bodyShell.receiveShadow = false;
bodyShell.renderOrder = 2;
const greenhouseMaterial = glassMaterial.clone();
greenhouseMaterial.color.setHex(0x769ead);
greenhouseMaterial.opacity = 0.12;
const greenhouse = makeTwinPanel(
THREE,
cabin,
makeVehicleGreenhouseShape(THREE),
2.64,
[0, 0, 0],
[0, -Math.PI / 2, 0],
greenhouseMaterial,
shellEdge,
);
greenhouse.castShadow = false;
greenhouse.renderOrder = 3;
const bodyPanelMaterial = shellMaterial.clone();
bodyPanelMaterial.color.setHex(0x6f9da8);
bodyPanelMaterial.transmission = 0.34;
bodyPanelMaterial.opacity = 0.3;
const exteriorTrim = new THREE.MeshStandardMaterial({
color: 0x1e2a2f,
roughness: 0.52,
metalness: 0.3,
});
const hood = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 2.68, 1.44, 0.18),
0.05,
[0, 1.43, 2.88],
[-Math.PI / 2 + 0.1, 0, 0],
bodyPanelMaterial,
shellEdge,
);
hood.castShadow = false;
hood.renderOrder = 3;
makeTwinBox(THREE, cabin, [2.82, 0.42, 0.14], [0, 0.57, 3.57], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [2.82, 0.38, 0.14], [0, 0.54, -3.55], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [1.1, 0.18, 0.055], [0, 0.61, 3.655], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.68, 0.055, 0.035], [0, 0.82, -3.635], exteriorTrim, invisibleEdge);
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.12, 0.2, 2.88], [side * 1.5, 0.34, 0.02], bodyPanelMaterial, shellEdge);
makeCabinBeam(THREE, cabin, [side * 1.615, 1.3, 1.82], [side * 1.615, 1.3, -2.56], 0.012, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.4, 0.18], [side * 1.62, 1.29, 0.18], 0.01, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.38, -1.48], [side * 1.62, 1.29, -1.48], 0.01, exteriorTrim);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, 0.58], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, -1.2], exteriorTrim, invisibleEdge);
const mirror = new THREE.Mesh(new THREE.SphereGeometry(0.18, 18, 12), bodyPanelMaterial);
mirror.scale.set(0.56, 0.68, 1.24);
mirror.position.set(side * 1.76, 1.48, 1.3);
mirror.castShadow = true;
cabin.add(mirror);
makeCabinBeam(THREE, cabin, [side * 1.52, 1.4, 1.31], [side * 1.68, 1.46, 1.3], 0.025, exteriorTrim);
}
[-1, 1].forEach((side) => {
const sideGlass = makeTwinPanel(
THREE,
cabin,
makeVehicleSideGlassShape(THREE),
0.025,
[side * 1.335, 0, 0],
[0, -Math.PI / 2, 0],
glassMaterial,
shellEdge,
);
sideGlass.castShadow = false;
sideGlass.renderOrder = 4;
});
const windshield = makeTwinBox(
THREE,
cabin,
[2.48, 1.43, 0.035],
[0, 1.88, 1.57],
glassMaterial,
shellEdge,
);
windshield.rotation.x = -0.49;
windshield.castShadow = false;
windshield.renderOrder = 4;
const rearGlass = makeTwinBox(
THREE,
cabin,
[2.38, 1.28, 0.035],
[0, 1.88, -2.26],
glassMaterial,
shellEdge,
);
rearGlass.rotation.x = 0.6;
rearGlass.castShadow = false;
rearGlass.renderOrder = 4;
const roofGlassMaterial = glassMaterial.clone();
roofGlassMaterial.color.setHex(0x173778);
roofGlassMaterial.emissive.setHex(0x153b82);
roofGlassMaterial.emissiveIntensity = 0.46;
roofGlassMaterial.transmission = 0;
roofGlassMaterial.opacity = 0.98;
roofGlassMaterial.depthWrite = true;
const roofGlass = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 1.08, 1.96, 0.16),
0.038,
[0, 2.43, -0.61],
[-Math.PI / 2, 0, 0],
roofGlassMaterial,
roofEntityEdge,
);
roofGlass.castShadow = false;
roofGlass.renderOrder = 7;
const roofFrameMaterial = new THREE.MeshStandardMaterial({
color: 0x94bfff,
emissive: 0x326fd2,
emissiveIntensity: 0.78,
roughness: 0.24,
metalness: 0.62,
depthTest: false,
});
const roofMeshes = [roofGlass];
[
[[1.22, 0.055, 0.075], [0, 2.455, 0.405]],
[[1.22, 0.055, 0.075], [0, 2.455, -1.625]],
[[0.075, 0.055, 1.96], [-0.59, 2.455, -0.61]],
[[0.075, 0.055, 1.96], [0.59, 2.455, -0.61]],
].forEach(([size, position]) => {
roofMeshes.push(makeTwinBox(
THREE,
cabin,
size,
position,
roofFrameMaterial,
invisibleEdge,
));
});
const roofShadeMaterial = new THREE.MeshStandardMaterial({
color: 0x5f6d71,
roughness: 0.86,
transparent: true,
opacity: 0.06,
depthWrite: false,
});
const roofShade = makeTwinBox(
THREE,
cabin,
[1.02, 0.025, 1.88],
[0, 2.39, -1.55],
roofShadeMaterial,
invisibleEdge,
);
roofShade.scale.z = 0.015;
roofShade.renderOrder = 6;
for (const side of [-1, 1]) {
makeCabinBeam(THREE, cabin, [side * 1.47, 1.25, 1.9], [side * 1.25, 2.51, 1.19], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.5, 1.25, -2.55], [side * 1.25, 2.48, -1.78], 0.05, trim);
makeCabinBeam(THREE, cabin, [side * 1.56, 1.25, -0.32], [side * 1.34, 2.54, -0.32], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.24, 2.57, 1.27], [side * 1.24, 2.57, -1.84], 0.045, trim);
}
makeCabinBeam(THREE, cabin, [-1.23, 2.58, 1.3], [1.23, 2.58, 1.3], 0.045, trim);
makeCabinBeam(THREE, cabin, [-1.23, 2.58, -1.9], [1.23, 2.58, -1.9], 0.045, trim);
const cabinFloor = makeTwinBox(
THREE,
cabin,
[2.86, 0.08, 5.45],
[0, 0.13, -0.05],
new THREE.MeshStandardMaterial({ color: 0x2b393e, roughness: 0.92, transparent: true, opacity: 0.5 }),
invisibleEdge,
);
cabinFloor.receiveShadow = true;
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.065, 0.62, 3.34], [side * 1.46, 0.67, -0.18], softTrim, invisibleEdge);
makeCabinBeam(THREE, cabin, [side * 1.415, 1.02, 1.3], [side * 1.415, 1.02, -1.76], 0.012, trim);
}
makeTwinBox(THREE, cabin, [0.42, 0.36, 1.55], [0, 0.4, 0.42], cockpit, invisibleEdge);
makeTwinBox(THREE, cabin, [0.38, 0.035, 1.28], [0, 0.6, 0.4], consoleTop, invisibleEdge);
const dashboard = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 2.55, 7, 28), softTrim);
dashboard.rotation.z = Math.PI / 2;
dashboard.position.set(0, 1.38, 1.63);
dashboard.scale.z = 1.5;
dashboard.castShadow = true;
cabin.add(dashboard);
const gloveboxLightHousing = makeTwinBox(
THREE,
cabin,
[1.18, 0.135, 0.075],
[-0.75, 1.265, 1.43],
ambientHousingMaterial,
invisibleEdge,
);
ambientMeshes.push(gloveboxLightHousing);
for (let index = 0; index < 9; index += 1) {
const lightSegment = makeTwinBox(
THREE,
cabin,
[0.09, 0.042, 0.028],
[-1.21 + index * 0.115, 1.315, 1.385],
accent,
invisibleEdge,
);
lightSegment.userData.twinRole = "passenger_glovebox_ambient_light";
ambientMeshes.push(lightSegment);
}
makeTwinBox(THREE, cabin, [0.54, 0.3, 0.055], [0.82, 1.62, 1.49], cockpit, invisibleEdge);
const steeringWheel = new THREE.Group();
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.036, 10, 36), cockpit);
steeringWheel.add(wheel);
makeTwinBox(THREE, steeringWheel, [0.035, 0.22, 0.028], [0, -0.1, 0], cockpit, invisibleEdge);
makeTwinBox(THREE, steeringWheel, [0.23, 0.035, 0.028], [0, -0.02, 0], cockpit, invisibleEdge);
steeringWheel.position.set(0.82, 1.48, 1.24);
steeringWheel.rotation.x = -0.34;
cabin.add(steeringWheel);
makeCabinBeam(THREE, cabin, [0.82, 1.43, 1.27], [0.82, 1.34, 1.57], 0.024, cockpit);
const tireMaterial = new THREE.MeshStandardMaterial({
color: 0x172126,
roughness: 0.92,
metalness: 0.05,
});
const rimMaterial = new THREE.MeshStandardMaterial({
color: 0x798a8f,
roughness: 0.38,
metalness: 0.72,
});
[-1, 1].forEach((side) => {
[-2.25, 2.35].forEach((z) => {
const tire = new THREE.Mesh(
new THREE.TorusGeometry(0.53, 0.15, 14, 32),
tireMaterial,
);
tire.position.set(side * 1.49, 0.58, z);
tire.rotation.y = Math.PI / 2;
tire.castShadow = true;
cabin.add(tire);
const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.11, 24), rimMaterial);
rim.position.copy(tire.position);
rim.rotation.z = Math.PI / 2;
cabin.add(rim);
const archLip = new THREE.Mesh(
new THREE.TorusGeometry(0.69, 0.035, 8, 36, Math.PI),
bodyPanelMaterial,
);
archLip.position.set(side * 1.615, 0.58, z);
archLip.rotation.y = Math.PI / 2;
archLip.castShadow = true;
cabin.add(archLip);
const spokeX = side * 1.555;
for (let spoke = 0; spoke < 5; spoke += 1) {
const angle = spoke * Math.PI * 2 / 5;
makeCabinBeam(
THREE,
cabin,
[spokeX, 0.58, z],
[spokeX, 0.58 + Math.cos(angle) * 0.27, z + Math.sin(angle) * 0.27],
0.022,
rimMaterial,
);
}
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 8), exteriorTrim);
hub.position.set(spokeX, 0.58, z);
hub.scale.x = 0.45;
cabin.add(hub);
});
});
const lampMaterial = new THREE.MeshStandardMaterial({
color: 0x697b80,
emissive: 0x000000,
emissiveIntensity: 0,
roughness: 0.56,
});
const rearLampMaterial = new THREE.MeshStandardMaterial({
color: 0x697b80,
emissive: 0x000000,
emissiveIntensity: 0,
roughness: 0.56,
});
for (const side of [-1, 1]) {
const headlamp = new THREE.Mesh(new THREE.SphereGeometry(0.16, 18, 10), lampMaterial);
headlamp.scale.set(2.6, 0.75, 0.24);
headlamp.position.set(side * 1.03, 1.05, 3.61);
cabin.add(headlamp);
const rearLamp = new THREE.Mesh(new THREE.SphereGeometry(0.15, 18, 10), rearLampMaterial);
rearLamp.scale.set(2.35, 0.7, 0.22);
rearLamp.position.set(side * 1.08, 1.08, -3.57);
cabin.add(rearLamp);
}
makeTwinBox(THREE, cabin, [1.08, 0.22, 0.035], [0, 0.72, 3.69], cockpit, invisibleEdge);
const ventMaterials = [];
const ventMeshes = [];
const ventCoreMaterial = new THREE.MeshStandardMaterial({
color: 0x07141c,
roughness: 0.5,
metalness: 0.34,
});
const ventBladeMaterial = new THREE.MeshStandardMaterial({
color: 0xd8f2f7,
emissive: 0x62dfff,
emissiveIntensity: 0.48,
roughness: 0.2,
metalness: 0.82,
depthTest: false,
});
[-1.14, -0.34, 0.34, 1.14].forEach((x) => {
const ventMaterial = new THREE.MeshStandardMaterial({
color: 0x176c94,
roughness: 0.24,
metalness: 0.55,
emissive: 0x2ccff3,
emissiveIntensity: 0.62,
});
ventMaterials.push(ventMaterial);
const vent = makeTwinBox(
THREE,
cabin,
[Math.abs(x) > 1 ? 0.47 : 0.44, 0.2, 0.135],
[x, 1.38, 1.405],
ventMaterial,
ventEntityEdge,
);
vent.userData.twinRole = "dashboard_hvac_outlet";
ventMeshes.push(vent);
const coreWidth = Math.abs(x) > 1 ? 0.365 : 0.34;
makeTwinBox(
THREE,
vent,
[coreWidth, 0.12, 0.145],
[0, 0, 0],
ventCoreMaterial,
invisibleEdge,
);
for (const bladeY of [-0.038, 0, 0.038]) {
makeTwinBox(
THREE,
vent,
[coreWidth * 0.9, 0.014, 0.025],
[0, bladeY, -0.083],
ventBladeMaterial,
invisibleEdge,
);
}
});
const rearVentMaterial = new THREE.MeshStandardMaterial({
color: 0x203741,
roughness: 0.34,
metalness: 0.42,
emissive: 0x2d91b6,
emissiveIntensity: 0.3,
});
ventMaterials.push(rearVentMaterial);
[-0.1, 0.1].forEach((x) => {
const vent = makeTwinBox(
THREE,
cabin,
[0.18, 0.13, 0.065],
[x, 0.48, -0.375],
rearVentMaterial,
ventEntityEdge,
);
ventMeshes.push(vent);
});
const airflow = createTwinAirflowField(THREE, cabin);
const ambientLights = [
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
];
ambientLights[0].position.set(-1.06, 1.22, 1.08);
ambientLights[1].position.set(-0.48, 1.22, 1.08);
ambientLights.forEach((light) => cabin.add(light));
const fallbackExteriorMaterials = new Set([
shellMaterial,
greenhouseMaterial,
bodyPanelMaterial,
exteriorTrim,
glassMaterial,
tireMaterial,
lampMaterial,
rearLampMaterial,
]);
const fallbackExteriorObjects = [];
const fallbackInteriorMaterials = new Set([trim, softTrim, cockpit, consoleTop]);
const fallbackInteriorObjects = [];
const worldPosition = new THREE.Vector3();
cabin.updateMatrixWorld(true);
cabin.traverse((object) => {
if (!object.isMesh) return;
const isFallbackMaterial = fallbackExteriorMaterials.has(object.material);
object.getWorldPosition(worldPosition);
const isWheelDetail = (
object.material === rimMaterial
&& Math.abs(worldPosition.z) > 1.8
);
if (isFallbackMaterial || isWheelDetail) fallbackExteriorObjects.push(object);
if (fallbackInteriorMaterials.has(object.material) || object === cabinFloor) {
fallbackInteriorObjects.push(object);
}
});
scene.add(cabin);
return {
root: cabin,
fallbackExteriorObjects,
fallbackInteriorObjects,
shellMaterial,
glassMaterial,
roofGlass,
roofMeshes,
roofGlassMaterial,
roofShade,
roofShadeMaterial,
ambientMaterial: accent,
ambientMeshes,
ambientLights,
ventMaterials,
ventMeshes,
airflow,
colors: {
ambientTarget: new THREE.Color(0x74d9ff),
ambientIdle: new THREE.Color(0x17323a),
climateTarget: new THREE.Color(0x55bdec),
roofClear: new THREE.Color(0x1f68b6),
roofDark: new THREE.Color(0x071b3e),
},
targets: {
ambientPower: 0,
ambientBrightness: 0,
climateLevel: 0,
climateTemperature: 24,
roofTransmittance: 100,
roofShadePosition: 0,
},
current: {
ambientBrightness: 0,
climateLevel: 0,
roofTransmittance: 100,
roofShadePosition: 0,
},
layout: {
roofShadeCenterZ: -0.61,
roofShadeHalfLength: 0.94,
},
};
}
function updateTwinVehicleVisual(systems, timestamp) {
if (!systems) return;
const ease = (current, target, factor = 0.08) => (
Math.abs(target - current) < 0.002 ? target : current + (target - current) * factor
);
systems.current.ambientBrightness = ease(
systems.current.ambientBrightness,
systems.targets.ambientPower ? systems.targets.ambientBrightness : 0,
);
systems.current.climateLevel = ease(
systems.current.climateLevel,
systems.targets.climateLevel,
);
systems.current.roofTransmittance = ease(
systems.current.roofTransmittance,
systems.targets.roofTransmittance,
);
systems.current.roofShadePosition = ease(
systems.current.roofShadePosition,
systems.targets.roofShadePosition,
);
const ambientStrength = systems.current.ambientBrightness / 100;
const ambientDisplayColor = systems.colors.ambientIdle.clone().lerp(
systems.colors.ambientTarget,
0.34 + ambientStrength * 0.66,
);
systems.ambientMaterial.color.lerp(ambientDisplayColor, 0.1);
systems.ambientMaterial.emissive.lerp(systems.colors.ambientTarget, 0.1);
systems.ambientMaterial.emissiveIntensity = 0.28 + ambientStrength * 2.15;
systems.ambientMaterial.transparent = false;
systems.ambientMaterial.opacity = 1;
systems.ambientMaterial.depthWrite = true;
systems.ambientLights.forEach((light) => {
light.color.lerp(systems.colors.ambientTarget, 0.1);
light.intensity = ambientStrength * 2.4;
});
const roofRatio = systems.current.roofTransmittance / 100;
const roofColor = systems.colors.roofDark.clone().lerp(systems.colors.roofClear, roofRatio);
systems.roofGlassMaterial.color.lerp(roofColor, 0.08);
systems.roofGlassMaterial.opacity = 0.86 + (1 - roofRatio) * 0.1;
const shadeCoverage = Math.max(0.015, systems.current.roofShadePosition / 100);
systems.roofShade.scale.z = shadeCoverage;
systems.roofShade.position.z = systems.layout.roofShadeCenterZ
- (1 - shadeCoverage) * systems.layout.roofShadeHalfLength;
systems.roofShadeMaterial.opacity = 0.08 + shadeCoverage * 0.82;
const climateStrength = systems.current.climateLevel / 7;
const climateColor = systems.targets.climateTemperature < 24
? 0x55bdec
: systems.targets.climateTemperature > 24
? 0xffa05d
: 0x72d1d0;
systems.colors.climateTarget.setHex(climateColor);
systems.airflow.level = ease(systems.airflow.level, climateStrength, 0.1);
systems.airflow.material.color.lerp(systems.colors.climateTarget, 0.12);
systems.airflow.material.opacity = systems.airflow.level * 0.92;
systems.airflow.material.size = 0.04 + systems.airflow.level * 0.055;
systems.airflow.points.visible = systems.airflow.level > 0.01;
systems.ventMaterials.forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.emissive.lerp(systems.colors.climateTarget, 0.12);
material.emissiveIntensity = 0.3 + systems.airflow.level * 2;
});
const speed = 0.00018 + systems.airflow.level * 0.00042;
systems.airflow.seeds.forEach((seed, index) => {
const progress = (timestamp * speed + seed) % 1;
const lane = index % 8;
const offset = index * 3;
systems.airflow.positions[offset] = -1.22 + lane * 0.13 + Math.sin(progress * 9 + index) * 0.035;
systems.airflow.positions[offset + 1] = 1.35 - progress * 0.5 + Math.sin(progress * Math.PI) * 0.13;
systems.airflow.positions[offset + 2] = 1.38 - progress * 1.68;
});
systems.airflow.attribute.needsUpdate = true;
}
function makeTwinPanel(THREE, parent, shape, depth, position, rotation, material, edgeMaterial) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 1,
curveSegments: 18,
bevelEnabled: true,
bevelSegments: 4,
bevelSize: 0.045,
bevelThickness: 0.04,
});
geometry.translate(0, 0, -depth / 2);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.rotation.set(...rotation);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 48), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeBackrestShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.7 - inset;
shape.moveTo(-0.52 + inset, 0);
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
shape.lineTo(-half + 0.035, 1.28 - inset);
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
return shape;
}
function makeCushionShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.69 - inset;
shape.moveTo(-0.52 + inset, 0.61 - inset);
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
shape.lineTo(-half + 0.025, -0.39 + inset);
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
return shape;
}
function makeTwinSeam(THREE, parent, points, material) {
const geometry = new THREE.BufferGeometry().setFromPoints(
points.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
);
const seam = new THREE.Line(geometry, material);
parent.add(seam);
return seam;
}
function createTwinVentField(THREE, parent, kind) {
const count = 12;
const positions = new Float32Array(count * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x39bdf2,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 18;
parent.add(points);
return {
kind,
points,
positions,
attribute: geometry.getAttribute("position"),
material,
};
}
function createTwinHeatPulse(THREE, parent, kind, phase) {
const material = new THREE.MeshBasicMaterial({
color: 0xff633f,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(
new THREE.RingGeometry(0.17, 0.205, 32),
material,
);
if (kind === "backrest") {
mesh.position.set(0, 0.88, 0.285);
mesh.scale.set(1.8, 3, 1);
} else {
mesh.position.set(0, 0.205, 0.04);
mesh.rotation.x = -Math.PI / 2;
mesh.scale.set(1.75, 2.35, 1);
}
mesh.visible = false;
mesh.renderOrder = 17;
parent.add(mesh);
return { mesh, material, phase, baseScale: mesh.scale.clone() };
}
function updateTwinThermalVisual(model, timestamp) {
const smoothing = 0.075;
["ventilation", "heating"].forEach((feature) => {
const delta = model.thermalTargets[feature] - model.thermalLevels[feature];
model.thermalLevels[feature] += Math.abs(delta) < 0.002 ? delta : delta * smoothing;
});
const ventilation = model.thermalLevels.ventilation;
const heating = model.thermalLevels.heating;
const selectionFactor = model.isSelected ? 1 : 0.58;
const targetColor = model.thermalColors.base.clone();
targetColor.lerp(model.thermalColors.ventilation, ventilation * 0.5);
targetColor.lerp(model.thermalColors.heating, heating * 0.6);
model.insertMaterial.color.lerp(targetColor, 0.1);
const targetEmissive = model.thermalColors.ventilation.clone().multiplyScalar(
ventilation * 0.14,
);
targetEmissive.lerp(
model.thermalColors.heating,
Math.min(1, heating * 0.34),
);
model.insertMaterial.emissive.lerp(targetEmissive, 0.1);
model.insertMaterial.emissiveIntensity = 0.12 + Math.max(ventilation, heating) * 0.38;
const elapsed = timestamp * (0.0003 + ventilation * 0.00018);
model.ventFields.forEach((field, fieldIndex) => {
field.points.visible = ventilation > 0.015;
field.material.opacity = ventilation * selectionFactor * 0.92;
field.material.size = 0.045 + ventilation * 0.045;
for (let index = 0; index < field.positions.length / 3; index += 1) {
const progress = (elapsed + index / 12 + fieldIndex * 0.17) % 1;
const column = (index % 3) - 1;
const row = Math.floor(index / 3);
const offset = index * 3;
if (field.kind === "backrest") {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.27 + progress * 1.28;
field.positions[offset + 2] = 0.29 + Math.sin(progress * Math.PI) * 0.22;
} else {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.205 + progress * 0.36;
field.positions[offset + 2] = -0.36 + row * 0.24 + Math.sin(progress * Math.PI) * 0.05;
}
}
field.attribute.needsUpdate = true;
});
model.heatPulses.forEach((pulse) => {
const progress = (timestamp * 0.00042 + pulse.phase) % 1;
const scale = 0.78 + progress * 0.42;
pulse.mesh.visible = heating > 0.015;
pulse.mesh.scale.copy(pulse.baseScale).multiplyScalar(scale);
pulse.material.opacity = heating * selectionFactor * (1 - progress) * 0.38;
});
}
function createTwinSeat(THREE, x, accentColor) {
const root = new THREE.Group();
root.position.set(x, 0.14, 0.05);
const shell = new THREE.MeshPhysicalMaterial({
color: 0x344950,
roughness: 0.5,
metalness: 0.01,
clearcoat: 0.16,
clearcoatRoughness: 0.72,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x9fb1b2,
roughness: 0.68,
metalness: 0,
clearcoat: 0.08,
});
const thermalInsert = insert.clone();
const bolster = new THREE.MeshStandardMaterial({ color: 0x52686f, roughness: 0.72 });
const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.64, metalness: 0.32 });
const rail = new THREE.MeshStandardMaterial({ color: 0x65757a, roughness: 0.45, metalness: 0.7 });
const edge = new THREE.LineBasicMaterial({ color: 0x182529, transparent: true, opacity: 0.34 });
const seam = new THREE.LineBasicMaterial({ color: accentColor, transparent: true, opacity: 0.88 });
const trackedMaterials = [shell, insert, thermalInsert, bolster];
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structure, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structure, edge);
const slide = new THREE.Group();
root.add(slide);
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structure);
base.rotation.x = Math.PI / 2;
base.scale.set(1.42, 1, 0.55);
base.position.set(0, 0.34, -0.02);
base.castShadow = true;
base.receiveShadow = true;
slide.add(base);
const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06);
slide.add(cushionPivot);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE),
0.22,
[0, 0, 0.08],
[-Math.PI / 2, 0, 0],
shell,
edge,
);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE, 0.16),
0.055,
[0, 0.145, 0.03],
[-Math.PI / 2, 0, 0],
thermalInsert,
edge,
);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolster);
wing.rotation.x = Math.PI / 2;
wing.position.set(side * 0.61, 0.17, 0.08);
wing.castShadow = true;
cushionPivot.add(wing);
}
makeTwinSeam(THREE, cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]], seam);
makeTwinSeam(THREE, cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]], seam);
const cushionVentField = createTwinVentField(THREE, cushionPivot, "cushion");
const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5);
slide.add(backrestPivot);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE), 0.3, [0, 0, 0], [0, 0, 0], shell, edge);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE, 0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert, edge);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolster);
wing.position.set(side * 0.61, 0.78, 0.18);
wing.scale.set(1, 1, 0.72);
wing.castShadow = true;
backrestPivot.add(wing);
}
makeTwinSeam(THREE, backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]], seam);
const backrestVentField = createTwinVentField(THREE, backrestPivot, "backrest");
const heatPulses = [
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0),
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0.5),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.18),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.68),
];
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], rail, edge);
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], rail, edge);
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), shell);
headrest.scale.set(1.35, 1, 0.5);
headrest.position.set(0, 1.98, 0.01);
headrest.castShadow = true;
backrestPivot.add(headrest);
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), insert);
headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18);
headrestInsert.castShadow = true;
backrestPivot.add(headrestInsert);
const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7);
slide.add(legrestPivot);
const legrestExtension = new THREE.Group();
legrestPivot.add(legrestExtension);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.08),
0.18,
[0, 0, 0.36],
[-Math.PI / 2, 0, 0],
shell,
edge,
).scale.set(0.9, 0.72, 0.54);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.2),
0.045,
[0, 0.12, 0.4],
[-Math.PI / 2, 0, 0],
insert,
edge,
).scale.set(0.92, 0.74, 0.56);
const outerSide = Math.sign(x);
const anchors = {};
const addAnchor = (name, parent, position) => {
const anchor = new THREE.Object3D();
anchor.position.set(...position);
parent.add(anchor);
anchors[name] = anchor;
};
addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]);
addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]);
addAnchor("backrest_recline", backrestPivot, [outerSide * 0.7, 1.28, 0.2]);
addAnchor("legrest_height", legrestExtension, [outerSide * 0.7, 0.08, 0.58]);
addAnchor("legrest_extension", legrestExtension, [outerSide * 0.7, 0.1, 0.82]);
addAnchor("backrest_included_angle", slide, [outerSide * 0.83, 0.62, -0.5]);
addAnchor("legrest_included_angle", slide, [-outerSide * 0.7, 0.51, 0.7]);
const angleGuides = {
backrest_recline: createTwinAngleGuide(THREE, slide, accentColor),
legrest_height: createTwinAngleGuide(THREE, slide, accentColor),
};
const defaultAngles = {
backrest_recline: twinAngleForPosition(
"backrest_recline",
TWIN_DEFAULT_POSITIONS.backrest_recline,
),
legrest_height: twinAngleForPosition(
"legrest_height",
TWIN_DEFAULT_POSITIONS.legrest_height,
),
};
return {
root,
slide,
cushionPivot,
backrestPivot,
legrestPivot,
legrestExtension,
anchors,
angleGuides,
outerSide,
trackedMaterials,
materials: {
shell,
insert,
thermalInsert,
bolster,
structure,
rail,
edge,
seam,
},
insertMaterial: thermalInsert,
isSelected: false,
thermalTargets: { ventilation: 0, heating: 0 },
thermalLevels: { ventilation: 0, heating: 0 },
thermalColors: {
base: new THREE.Color(0x9fb1b2),
ventilation: new THREE.Color(0x4b93b7),
heating: new THREE.Color(0xd05f43),
},
ventFields: [cushionVentField, backrestVentField],
heatPulses,
positions: { ...TWIN_DEFAULT_POSITIONS },
targets: { ...TWIN_DEFAULT_POSITIONS },
angles: { ...defaultAngles },
angleTargets: { ...defaultAngles },
};
}
function configureFixedTwinSeat(THREE, model, position, role) {
const cushionAngle = 6.5;
const backrestIncludedAngle = 104;
const legrestIncludedAngle = 95;
model.root.position.set(...position);
model.root.scale.setScalar(0.84);
model.root.userData.twinRole = role;
model.slide.position.z = 0;
model.cushionPivot.rotation.x = THREE.MathUtils.degToRad(-cushionAngle);
model.backrestPivot.rotation.x = (
model.cushionPivot.rotation.x
- THREE.MathUtils.degToRad(backrestIncludedAngle - 90)
);
model.legrestPivot.rotation.x = THREE.MathUtils.degToRad(
180 - cushionAngle - legrestIncludedAngle,
);
model.legrestExtension.position.z = 0;
Object.values(model.angleGuides).forEach((guide) => {
guide.group.visible = false;
});
model.ventFields.forEach((field) => {
field.points.visible = false;
});
model.heatPulses.forEach((pulse) => {
pulse.mesh.visible = false;
});
return model;
}
function setTwinSelection(selected) {
const twin = state.seatTwin;
if (!twin) return;
const focusedSeat = "passenger";
twin.selected = focusedSeat;
$("#seatTwin").dataset.selectedSeat = focusedSeat;
$$(".twin-seat-button").forEach((button) => {
button.classList.toggle("active", button.dataset.twinSeat === focusedSeat);
});
Object.entries(twin.models).forEach(([seat, model]) => {
const isSelected = seat === focusedSeat;
model.isSelected = isSelected;
Object.entries(model.angleGuides).forEach(([axis, guide]) => {
guide.group.visible = (
isSelected
&& twin.explicitAngleTargets[focusedSeat]?.[axis] != null
);
});
model.trackedMaterials.forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.depthTest = true;
material.needsUpdate = true;
});
});
twin.view.targetX = 0;
twin.view.yaw = -0.62;
renderTwinReadout();
}
function renderTwinCalloutValues(device) {
const value = (axis, fallback) => Number(device?.position?.[axis]?.position_pct ?? fallback);
const angle = (axis, fallbackPosition) => Number(
device?.position?.[axis]?.angle_deg
?? twinAngleForPosition(axis, value(axis, fallbackPosition)),
);
const longitudinal = value("longitudinal", 50);
const cushion = value("cushion_tilt", 50);
const backrest = value("backrest_recline", 20);
const legrest = value("legrest_height", 0);
const extension = value("legrest_extension", 0);
const postureAngles = twinPostureAngles(device);
const explicitAngles = state.seatTwin?.explicitAngleTargets[
state.seatTwin.selected
] || {};
const backrestEstimated = isEstimatedMotion(device, "backrest_recline");
const legrestEstimated = isEstimatedMotion(device, "legrest_height");
const formatAngle = (number) => (
Number.isInteger(number) ? String(number) : number.toFixed(1)
);
const values = {
longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "后移区" : longitudinal > 55 ? "前移区" : "中位区"],
cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`],
backrest_recline: [
`${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`,
`${backrestEstimated ? "估算 " : ""}${backrest}% · 相对竖直`,
],
legrest_height: [
`${legrestEstimated ? "≈" : ""}${formatAngle(postureAngles.legrest_height)}°`,
`${legrestEstimated ? "估算 " : ""}${legrest}% · 相对地板`,
],
legrest_extension: [`${extension}%`, "伸缩行程"],
backrest_included_angle: [
`${backrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.backrest_recline ?? angle("backrest_recline", 20))}°`,
backrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
legrest_included_angle: [
`${legrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.legrest_height ?? angle("legrest_height", 0))}°`,
legrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
};
Object.entries(values).forEach(([axis, [primary, secondary]]) => {
const callout = $(`[data-callout="${axis}"]`);
$("b", callout).textContent = primary;
$("small", callout).textContent = secondary;
});
}
function renderTwinReadout() {
const twin = state.seatTwin;
if (!twin) return;
const seat = twin.selected;
const device = twin.actuatorState[seat];
$("#twinSeatName").textContent = seat === "driver" ? "主驾" : "副驾";
$("#twinSeatZone").textContent = seat === "driver" ? "front_left" : "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 = { driver: {}, 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.52, fillOpacity: 0.014, renderOrder: 30 },
glass: { color: 0x78a7b2, lineOpacity: 0.18, fillOpacity: 0.006, renderOrder: 31 },
detail: { color: 0x536f76, lineOpacity: 0.28, fillOpacity: 0.009, renderOrder: 32 },
wheel: { color: 0x203d45, lineOpacity: 0.68, fillOpacity: 0.02, renderOrder: 33 },
interior: { color: 0x607e83, lineOpacity: 0.24, fillOpacity: 0.008, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0.28, fillOpacity: 0.009, renderOrder: 32 },
};
function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) {
if (!mesh.geometry?.attributes?.position || mesh.userData.twinOutline) return null;
const geometry = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle);
if (!geometry.getAttribute("position")?.count) {
geometry.dispose();
return null;
}
const material = new THREE.LineBasicMaterial({
color: style.color,
transparent: true,
opacity: style.lineOpacity,
depthWrite: false,
depthTest: true,
toneMapped: false,
});
const outline = new THREE.LineSegments(geometry, material);
outline.name = `${mesh.name || "mesh"}_outline`;
outline.renderOrder = style.renderOrder;
outline.frustumCulled = mesh.frustumCulled;
mesh.add(outline);
mesh.userData.twinOutline = outline;
return outline;
}
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;
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
material.transparent = Boolean(options.transparent);
material.opacity = options.opacity ?? 1;
material.depthWrite = options.depthWrite ?? !options.transparent;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
});
object.castShadow = true;
object.receiveShadow = true;
object.renderOrder = options.renderOrder ?? 46;
});
});
}
function configureActivePassengerSeatStyle(model) {
const {
shell,
insert,
thermalInsert,
bolster,
structure,
rail,
edge,
seam,
} = model.materials;
shell.color.setHex(0xd85b27);
shell.roughness = 0.3;
shell.clearcoat = 0.58;
shell.emissive.setHex(0x64210c);
shell.emissiveIntensity = 0.24;
insert.color.setHex(0xffe0a3);
insert.emissive.setHex(0x6d4315);
insert.emissiveIntensity = 0.2;
thermalInsert.color.setHex(0xffc866);
thermalInsert.emissive.setHex(0x7a3d0d);
thermalInsert.emissiveIntensity = 0.24;
bolster.color.setHex(0xb93426);
bolster.emissive.setHex(0x53120d);
bolster.emissiveIntensity = 0.2;
structure.color.setHex(0x39231d);
structure.emissive.setHex(0x1b0d09);
structure.emissiveIntensity = 0.14;
rail.color.setHex(0xa8bdc5);
rail.metalness = 0.82;
[shell, insert, thermalInsert, bolster, structure, rail].forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.depthTest = true;
material.alphaTest = 0;
material.colorWrite = true;
material.toneMapped = false;
if ("transmission" in material) material.transmission = 0;
material.needsUpdate = true;
});
edge.color.setHex(0xffd592);
edge.opacity = 1;
edge.depthTest = false;
seam.color.setHex(0xfff1cd);
seam.opacity = 1;
seam.depthTest = false;
model.thermalColors.base.setHex(0xffc866);
model.root.traverse((object) => {
if (!object.isLine && !object.isLineSegments) return;
object.renderOrder = 55;
});
}
function visibleTwinMeshBounds(THREE, root) {
const bounds = new THREE.Box3();
const meshBounds = new THREE.Box3();
root.updateWorldMatrix(true, true);
root.traverse((object) => {
if (!object.isMesh || !object.visible || !object.geometry?.attributes?.position) return;
if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
meshBounds.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld);
bounds.union(meshBounds);
});
return bounds;
}
function configureReferenceVehicleModel(THREE, vehicle) {
const materialCache = new Map();
const report = { meshes: 0, materials: new Map(), nodes: [] };
vehicle.traverse((object) => {
if (object.name) report.nodes.push(object.name);
if (!object.isMesh) return;
report.meshes += 1;
object.castShadow = false;
object.receiveShadow = false;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const configuredMaterials = sourceMaterials.map((sourceMaterial) => {
if (!sourceMaterial) return sourceMaterial;
if (materialCache.has(sourceMaterial)) return materialCache.get(sourceMaterial);
const material = sourceMaterial.clone();
const descriptor = referenceMaterialDescriptor(material);
const category = classifyReferenceMaterial(material);
const key = `${category}:${material.name || "unnamed"}`;
report.materials.set(key, (report.materials.get(key) || 0) + 1);
if (material.map) {
material.map.colorSpace = THREE.SRGBColorSpace;
material.map.anisotropy = 8;
}
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);
}
const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail;
material.transparent = true;
material.opacity = outlineStyle.fillOpacity;
material.depthWrite = false;
material.side = THREE.DoubleSide;
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.needsUpdate = true;
materialCache.set(sourceMaterial, material);
return material;
});
object.material = Array.isArray(object.material) ? configuredMaterials : configuredMaterials[0];
const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial);
if (categories.length && categories.every((category) => category === "shadow")) object.visible = false;
if (!object.visible) return;
const categoryPriority = ["light", "interior", "wheel", "detail", "glass", "body"];
const outlineCategory = categoryPriority.find((category) => categories.includes(category)) || "detail";
const outlineStyle = TWIN_OUTLINE_STYLE[outlineCategory];
object.renderOrder = outlineStyle.renderOrder - 1;
addTwinMeshOutline(THREE, object, outlineStyle, 30);
});
return report;
}
function normalizeReferenceVehicle(THREE, vehicle) {
vehicle.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(vehicle);
let size = bounds.getSize(new THREE.Vector3());
if (size.x > size.z) {
vehicle.rotateY(Math.PI / 2);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
size = bounds.getSize(new THREE.Vector3());
}
const scale = 7.45 / Math.max(size.x, size.z);
vehicle.scale.multiplyScalar(scale);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
const center = bounds.getCenter(new THREE.Vector3());
vehicle.position.x -= center.x;
vehicle.position.z -= center.z;
vehicle.position.y += -0.145 - bounds.min.y;
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
return { bounds, size: bounds.getSize(new THREE.Vector3()), scale };
}
function measureReferenceDriverCushion(THREE, vehicle) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return null;
interior.updateWorldMatrix(true, false);
const geometry = interior.geometry.index
? interior.geometry.toNonIndexed()
: interior.geometry;
const positions = geometry.getAttribute("position");
const centroid = new THREE.Vector3();
const vertex = new THREE.Vector3();
const samples = [];
for (let offset = 0; offset < positions.count; offset += 3) {
centroid.set(0, 0, 0);
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
vertex.fromBufferAttribute(positions, offset + vertexOffset);
vertex.applyMatrix4(interior.matrixWorld);
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
const insideDriverCushion = (
centroid.x >= 0.18
&& centroid.x <= 1.18
&& centroid.y >= 0.25
&& centroid.y <= 1.15
&& centroid.z >= -0.12
&& centroid.z <= 0.98
);
if (insideDriverCushion) samples.push(centroid.y);
}
if (geometry !== interior.geometry) geometry.dispose();
if (samples.length < 20) return null;
samples.sort((left, right) => left - right);
const quantile = (ratio) => samples[Math.min(
samples.length - 1,
Math.floor((samples.length - 1) * ratio),
)];
return {
triangleCount: samples.length,
medianY: quantile(0.5),
upperQuartileY: quantile(0.75),
surfaceY: quantile(0.9),
};
}
function carveReferencePassengerSeat(THREE, vehicle) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return 0;
interior.updateWorldMatrix(true, false);
const source = interior.geometry.index
? interior.geometry.toNonIndexed()
: interior.geometry.clone();
const positions = source.getAttribute("position");
const centroid = new THREE.Vector3();
const vertex = new THREE.Vector3();
const keptVertices = [];
let removedTriangles = 0;
for (let offset = 0; offset < positions.count; offset += 3) {
centroid.set(0, 0, 0);
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
vertex.fromBufferAttribute(positions, offset + vertexOffset);
vertex.applyMatrix4(interior.matrixWorld);
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
const insidePassengerSeat = (
centroid.x >= -1.15
&& centroid.x <= -0.12
&& centroid.y >= 0.05
&& centroid.y <= 2.05
&& centroid.z >= -0.92
&& centroid.z <= 1.08
);
if (insidePassengerSeat) {
removedTriangles += 1;
continue;
}
keptVertices.push(offset, offset + 1, offset + 2);
}
if (removedTriangles < 40) {
source.dispose();
return 0;
}
const carved = new THREE.BufferGeometry();
Object.entries(source.attributes).forEach(([name, attribute]) => {
const values = new attribute.array.constructor(keptVertices.length * attribute.itemSize);
keptVertices.forEach((sourceIndex, targetIndex) => {
for (let component = 0; component < attribute.itemSize; component += 1) {
values[targetIndex * attribute.itemSize + component] = (
attribute.array[sourceIndex * attribute.itemSize + component]
);
}
});
carved.setAttribute(
name,
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
);
});
carved.computeBoundingBox();
carved.computeBoundingSphere();
interior.geometry = carved;
source.dispose();
return removedTriangles;
}
function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
return new Promise((resolve, reject) => {
const FBXLoaderClass = window.FBXLoader || THREE.FBXLoader;
if (!FBXLoaderClass) {
reject(new Error("FBXLoader is unavailable"));
return;
}
const manager = new THREE.LoadingManager();
manager.setURLModifier((url) => {
const cleanUrl = String(url).replaceAll("\\", "/");
const fileName = decodeURIComponent(cleanUrl.split("/").pop() || "");
if (!/\.(png|jpe?g)$/i.test(fileName)) return url;
return `${REFERENCE_VEHICLE_ROOT}${encodeURIComponent(fileName)}`;
});
const loader = new FBXLoaderClass(manager);
loader.setResourcePath(REFERENCE_VEHICLE_ROOT);
loader.load(
REFERENCE_VEHICLE_URL,
(vehicle) => {
const normalized = normalizeReferenceVehicle(THREE, vehicle);
const driverCushion = measureReferenceDriverCushion(THREE, vehicle);
const removedPassengerTriangles = carveReferencePassengerSeat(THREE, vehicle);
const report = configureReferenceVehicleModel(THREE, vehicle);
vehicle.name = "REFERENCE_VEHICLE_OUTLINE";
vehicle.userData.twinRole = "reference_vehicle";
scene.add(vehicle);
vehicleSystems.fallbackExteriorObjects.forEach((object) => {
object.visible = false;
});
vehicleSystems.fallbackInteriorObjects.forEach((object) => {
object.visible = false;
});
console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({
meshes: report.meshes,
size: normalized.size.toArray().map((value) => Number(value.toFixed(3))),
removedPassengerTriangles,
driverCushion,
}));
resolve({ vehicle, report, normalized, driverCushion });
},
(event) => {
if (!fallback || !event.total) return;
const progress = Math.min(99, Math.round(event.loaded / event.total * 100));
const label = fallback.querySelector("span");
if (label) label.textContent = `正在加载整车参考模型 · ${progress}%`;
},
reject,
);
});
}
function initSeatTwin() {
const canvas = $("#seatTwinCanvas");
const stage = $("#seatTwin");
const fallback = $("#twinFallback");
if (!canvas || !stage) return;
if (!window.THREE) {
fallback.classList.add("error");
fallback.innerHTML = '<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.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);
scene.add(new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2));
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
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.position.set(5, 3, -5);
scene.add(fillLight);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 9),
new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(10, 20, 0xaebdb9, 0xcbd6d2);
grid.position.y = -0.155;
grid.material.transparent = true;
grid.material.opacity = 0.16;
scene.add(grid);
const vehicleSystems = createTwinCabin(THREE, scene);
applyTwinOutlineStyle(THREE, vehicleSystems.root, {
color: 0x4f747c,
lineOpacity: 0.46,
fillOpacity: 0.012,
renderOrder: 40,
thresholdAngle: 30,
});
configureTwinSolidEntity(THREE, vehicleSystems.ventMeshes, {
renderOrder: 52,
});
configureTwinSolidEntity(THREE, vehicleSystems.ambientMeshes, {
renderOrder: 56,
});
configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes, {
transparent: false,
opacity: 1,
depthWrite: true,
renderOrder: 54,
});
configureTwinSolidEntity(THREE, vehicleSystems.roofShade, {
transparent: true,
opacity: 0.08,
renderOrder: 49,
});
const models = {
passenger: createTwinSeat(THREE, -1.05, 0x4a78af),
};
configureActivePassengerSeatStyle(models.passenger);
models.passenger.root.scale.setScalar(0.5);
models.passenger.root.position.set(-0.62, 0.38, 0.25);
models.passenger.root.userData.twinRole = "active_front_passenger";
configureTwinSolidEntity(THREE, models.passenger.root, {
renderOrder: 53,
});
scene.add(models.passenger.root);
const fixedModels = {
driver: configureFixedTwinSeat(
THREE,
createTwinSeat(THREE, 0.82, 0x667f84),
[0.82, 0.18, -0.25],
"fixed_front_driver",
),
rear: createTwinRearBench(THREE),
};
applyTwinOutlineStyle(THREE, fixedModels.driver.root, {
color: 0x5e7479,
lineOpacity: 0.46,
fillOpacity: 0.01,
renderOrder: 41,
thresholdAngle: 26,
});
applyTwinOutlineStyle(THREE, fixedModels.rear, {
color: 0x5e7479,
lineOpacity: 0.42,
fillOpacity: 0.01,
renderOrder: 41,
thresholdAngle: 26,
});
scene.add(fixedModels.driver.root);
vehicleSystems.root.add(fixedModels.rear);
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: { driver: {}, passenger: {} },
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
dragging: false,
pointer: { x: 0, y: 0 },
};
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
.then(({ vehicle, report, normalized, driverCushion }) => {
if (!state.seatTwin) return;
state.seatTwin.referenceVehicle = vehicle;
state.seatTwin.referenceReport = report;
state.seatTwin.referenceBounds = normalized.bounds;
fixedModels.driver.root.visible = false;
fixedModels.rear.visible = false;
if (driverCushion) {
const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot);
const verticalCorrection = THREE.MathUtils.clamp(
driverCushion.surfaceY - cushionBoundsBefore.max.y,
-0.08,
0.42,
);
models.passenger.root.position.y += verticalCorrection;
models.passenger.root.updateMatrixWorld(true);
const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models.passenger.cushionPivot);
models.passenger.root.userData.cushionSurfaceY = cushionBoundsAfter.max.y;
canvas.dataset.activeSeatCushionSurface = cushionBoundsAfter.max.y.toFixed(3);
canvas.dataset.activeSeatRootY = models.passenger.root.position.y.toFixed(3);
canvas.dataset.referenceDriverCushionSurface = driverCushion.surfaceY.toFixed(3);
canvas.dataset.referenceDriverCushionSamples = String(driverCushion.triangleCount);
canvas.dataset.seatCushionAligned = String(
Math.abs(cushionBoundsAfter.max.y - driverCushion.surfaceY) < 0.025
);
}
models.passenger.root.updateMatrixWorld(true);
const activeSeatBounds = new THREE.Box3().setFromObject(models.passenger.root);
const activeSeatSize = activeSeatBounds.getSize(new THREE.Vector3());
const activeSeatContained = (
normalized.bounds.containsPoint(activeSeatBounds.min)
&& normalized.bounds.containsPoint(activeSeatBounds.max)
);
canvas.dataset.activeSeatContained = String(activeSeatContained);
canvas.dataset.activeSeatSize = activeSeatSize
.toArray()
.map((value) => value.toFixed(2))
.join("x");
canvas.dataset.referenceModelLoaded = "true";
fallback.hidden = true;
})
.catch((error) => {
console.error("[vehicle-twin] failed to load reference vehicle model", error);
fallback.classList.add("error");
fallback.innerHTML = '<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 = {
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 = 2 + model.positions.cushion_tilt * 0.09;
model.cushionPivot.rotation.x = degrees(-cushionAngle);
model.backrestPivot.rotation.x = (
model.cushionPivot.rotation.x
- degrees(model.angles.backrest_recline - 90)
);
model.legrestPivot.rotation.x = degrees(
180 - cushionAngle - model.angles.legrest_height,
);
model.legrestExtension.position.z = model.positions.legrest_extension * 0.0022;
updateTwinAngleGuide(
model.angleGuides.backrest_recline,
model.outerSide * 0.83,
0.62,
-0.5,
cushionAngle,
model.angles.backrest_recline,
0.58,
);
updateTwinAngleGuide(
model.angleGuides.legrest_height,
-model.outerSide * 0.7,
0.64,
0.7,
cushionAngle + 180,
model.angles.legrest_height,
0.48,
);
updateTwinThermalVisual(model, timestamp);
});
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
const { view } = twin;
const cameraDistance = view.distance * (twin.narrowViewport ? 1.08 : 1);
const horizontal = Math.cos(view.pitch) * cameraDistance;
const cameraTargetX = view.targetX;
camera.position.set(
cameraTargetX + Math.sin(view.yaw) * horizontal,
0.82 + Math.sin(view.pitch) * cameraDistance,
Math.cos(view.yaw) * horizontal,
);
camera.lookAt(cameraTargetX, 1.14, -0.08);
positionTwinCallouts();
renderer.render(scene, camera);
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
window.requestAnimationFrame(animate);
}
setTwinSelection("passenger");
animate();
}
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
}
function selectProvider(provider) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
button.classList.toggle("active", button.dataset.provider === provider);
});
}
function selectExecutionMode(mode) {
if (mode === "hardware" && !state.runtime?.hardware?.configured) return;
state.executionMode = mode;
$$('[data-execution-mode]').forEach((button) => {
button.classList.toggle("active", button.dataset.executionMode === mode);
});
const hardware = mode === "hardware";
$("#approvalField").hidden = !hardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
? '<i data-lucide="radio-tower"></i>HIL · 真实 CAN'
: '<i data-lucide="flask-conical"></i>DRY-RUN';
$("#safetyTitle").textContent = hardware ? "真实台架安全门禁" : "虚拟执行边界";
$("#safetyText").textContent = hardware
? "副驾座椅映射到统一实体执行器;绝对位置会实时检查板端标定,小幅相对移动可走受限定时/Hall 回读,异常会尝试 STOP_ALL。"
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
$("#executeStageLabel").textContent = hardware ? "台架执行" : "虚拟执行";
$("#feedbackModeLabel").textContent = hardware
? "T2G BENCH · PHYSICAL CAN FEEDBACK"
: "MATRIX-BACKED · SIMULATED FEEDBACK";
$("#seatFeedbackModeLabel").textContent = hardware
? "PHYSICAL CAN FEEDBACK"
: "SIMULATED FEEDBACK";
refreshIcons();
}
async function loadRuntime() {
try {
const response = await fetch("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
const hardware = state.runtime.hardware || {};
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}`;
$("#providerNote").textContent = `${state.runtime.openai_model} · Responses API · 结构化输出`;
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 = !hardware.configured;
const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
$("#hardwareNote").textContent = hardware.configured
? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText}`
: `真实执行已锁定:${profileText};请完成服务端启用、令牌、适配器和固件镜像配置`;
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
? "未检测到匹配固件时,将在发送座椅控制帧前通过 IAR C-SPY 自动烧录并重新验证。"
: "仅接受已烧录且身份匹配的统一固件;需要自动烧录时请启用服务端开关。";
$("#hardwareBadge").className = `status-badge ${hardware.configured ? "" : "danger"}`.trim();
$("#hardwareBadge").innerHTML = hardware.configured
? '<i data-lucide="shield-check"></i>硬件已配置'
: '<i data-lucide="shield-off"></i>硬件锁定';
$("#hardwareCoverage").textContent = hardware.configured ? "可选 · 需授权" : "已阻断";
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
selectExecutionMode("dry_run");
refreshIcons();
} catch (error) {
$("#modelBadge").textContent = "运行时离线";
$("#modelBadge").classList.add("danger");
$("#providerNote").textContent = "无法读取后端运行状态";
showToast(`运行时连接失败:${error.message}`);
}
}
async function loadAuthStatus() {
const response = await fetch("/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 fetch("/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 renderSeatState(actuatorState) {
resetSeatState();
if (!actuatorState) return;
Object.entries(actuatorState).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(actuatorState);
}
function renderDomainState(domainState) {
if (!domainState) return;
const climate = domainState.climate;
if (climate) {
const active = climate.power === "on" || climate.ac === "on" || Number(climate.fan_level) > 0;
const driverTemperature = climate.zones?.driver?.temperature_c
?? climate.zones?.vehicle?.temperature_c
?? 24;
const passengerTemperature = climate.zones?.passenger?.temperature_c
?? climate.zones?.vehicle?.temperature_c
?? 24;
$('[data-domain-panel="climate"]').classList.toggle("active", active);
$("#climatePower").textContent = climate.power === "on" ? "ON" : (active ? "ACTIVE" : "OFF");
$("#driverTemperature").innerHTML = `${driverTemperature}<sup>℃</sup>`;
$("#passengerTemperature").innerHTML = `${passengerTemperature}<sup>℃</sup>`;
$("#airflowVisual").dataset.level = String(Math.max(0, Math.min(7, Number(climate.fan_level) || 0)));
$("#fanLevel").textContent = `${climate.fan_level ?? 0} 档`;
$("#cycleMode").textContent = climate.cycle_mode === "internal" ? "内循环" : "外循环";
$("#acState").textContent = climate.ac === "on" ? "开启" : "关闭";
$("#defrostState").textContent = climate.front_defrost === "on" ? "开启" : "关闭";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.climateLevel = Math.max(0, Math.min(7, Number(climate.fan_level) || 0));
vehicleSystems.targets.climateTemperature = Number(passengerTemperature) || 24;
}
$('[data-twin-system="climate"]').classList.toggle("active", active);
$("#twinClimateState").textContent = active
? `${passengerTemperature}℃ · ${climate.fan_level ?? 0}档`
: "关闭";
}
const roof = domainState.roof;
if (roof) {
const transmittance = Math.max(0, Math.min(100, Number(roof.transmittance_pct) || 0));
const shadePosition = Math.max(0, Math.min(100, Number(roof.shade_position_pct) || 0));
$('[data-domain-panel="roof"]').classList.toggle(
"active",
transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on",
);
$("#roofGlass").style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`;
$("#roofShade").style.width = `${100 - shadePosition}%`;
$("#transmittanceValue").textContent = `${transmittance}%`;
$("#shadePositionValue").textContent = `${shadePosition}%`;
$("#automaticDimming").textContent = roof.automatic_dimming === "on" ? "开启" : "关闭";
$("#roofMotorState").textContent = roof.fault
? "故障"
: (roof.motion_state === "stopped" ? "已停止" : roof.motion_state);
$("#roofState").textContent = roof.fault ? "FAULT" : "READY";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.roofTransmittance = transmittance;
vehicleSystems.targets.roofShadePosition = shadePosition;
}
const roofActive = transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on";
$('[data-twin-system="roof"]').classList.toggle("active", roofActive);
$("#twinRoofState").textContent = `透光 ${transmittance}% · 帘 ${shadePosition}%`;
}
const ambient = domainState.ambient_light;
if (ambient) {
const active = ambient.power === "on";
const color = /^#[0-9A-Fa-f]{6}$/.test(ambient.color_hex || "")
? ambient.color_hex.toUpperCase()
: "#74D9FF";
const brightness = Math.max(0, Math.min(100, Number(ambient.brightness_pct) || 0));
const opacity = active ? Math.max(0.18, brightness / 100) : 0.08;
const modeLabels = {
camping: "露营",
rest: "小憩",
cinema: "观影",
meditation: "冥想",
romantic: "浪漫",
custom: "自定义",
on: "开启",
off: "关闭",
};
$('[data-domain-panel="ambient_light"]').classList.toggle("active", active);
$("#ambientPower").textContent = active ? "ON" : "OFF";
$$(".ambient-line").forEach((line) => {
line.style.background = color;
line.style.boxShadow = `0 0 ${Math.round(5 + brightness / 8)}px ${color}`;
line.style.opacity = String(opacity);
});
$("#ambientSwatch").style.background = color;
$("#ambientColor").textContent = color;
$("#ambientBrightness").textContent = `${brightness}%`;
$("#ambientMode").textContent = modeLabels[ambient.mode] || ambient.mode || "自定义";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.ambientPower = active ? 1 : 0;
vehicleSystems.targets.ambientBrightness = brightness;
vehicleSystems.colors.ambientTarget.set(color);
}
$('[data-twin-system="ambient"]').classList.toggle("active", active);
$("#twinAmbientState").textContent = active
? `${color} · ${brightness}%`
: "关闭";
}
}
function renderedConfig(result) {
if (result.scene_config) {
return {
scene_config: result.scene_config,
function_configs: result.function_configs,
};
}
return result.preset_config || result.function_config;
}
function renderedPlan(result) {
const plans = result.command_plans || {};
return Object.keys(plans).length > 1 ? plans : result.command_plan;
}
function renderSavedPresets(result) {
const indicator = $("#presetIndicator");
const presets = result.saved_presets || [];
if (!presets.length) {
indicator.hidden = true;
return;
}
const latest = result.preset_config
? {
name: result.preset_config.metadata.name,
target: result.preset_config.metadata.labels.target,
}
: presets[presets.length - 1];
const targetName = latest.target === "driver" ? "主驾" : "副驾";
$("span", indicator).textContent = `已保存 ${targetName} · ${latest.name}`;
indicator.hidden = false;
}
const CONFIG_DOMAIN_META = {
seat: { label: "副驾座椅", icon: "armchair", className: "seat" },
climate: { label: "空调", icon: "fan", className: "climate" },
ambient_light: { label: "氛围灯", icon: "lamp-ceiling", className: "ambient" },
roof: { label: "智能天幕", icon: "panels-top-left", className: "roof" },
};
const CONFIG_CAPABILITY_LABELS = {
"seat.set_ventilation": "设置座椅通风",
"seat.set_heating": "设置座椅加热",
"seat.set_longitudinal": "调节座椅前后位置",
"seat.set_longitudinal_position": "调节座椅前后位置",
"seat.set_cushion_tilt": "调节坐垫俯仰",
"seat.set_backrest_recline": "调节靠背角度",
"seat.set_legrest_height": "调节腿托角度",
"seat.set_legrest_extension": "调节腿托伸缩",
"climate.set_temperature": "设置空调温度",
"climate.set_fan_level": "设置空调风量",
"climate.set_power": "设置空调电源",
"climate.set_ac": "设置 A/C",
"climate.set_cycle_mode": "设置内外循环",
"climate.set_outlet_mode": "设置出风模式",
"climate.set_front_defrost": "设置前风挡除霜",
"ambient_light.set_power": "设置氛围灯电源",
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
"ambient_light.set_color": "设置氛围灯颜色",
"ambient_light.set_brightness": "设置氛围灯亮度",
"ambient_light.set_mode": "设置氛围灯模式",
"roof.set_transmittance": "设置天幕透光率",
"roof.set_shade_position": "设置遮阳帘位置",
"roof.set_automatic_dimming": "设置自动调光",
};
const CONFIG_PARAMETER_LABELS = {
power: "电源",
state: "状态",
level: "档位",
temperature_c: "目标温度",
fan_level: "目标风量",
position_pct: "目标位置",
angle_deg: "目标角度",
color_hex: "目标颜色",
brightness_pct: "目标亮度",
transmittance_pct: "目标透光率",
shade_position_pct: "遮阳帘位置",
automatic_dimming: "自动调光",
cycle_mode: "循环模式",
outlet_mode: "出风模式",
mode: "工作模式",
};
function makeConfigElement(tagName, className, textValue) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (textValue != null) element.textContent = textValue;
return element;
}
function configTargetLabel(target = {}, domain) {
const scopes = {
driver: "主驾",
passenger: "副驾",
front_left: "主驾",
front_right: "副驾",
front_row: "前排",
rear_row: "后排",
vehicle: "全车",
cabin: "全车舱",
};
const rawScope = target.instance || target.zone || (domain === "seat" ? "passenger" : "vehicle");
const scope = scopes[rawScope] || rawScope;
if (domain === "seat") return `${scope}座椅`;
const device = CONFIG_DOMAIN_META[domain]?.label || target.device || domain;
return `${scope} · ${device}`;
}
function formatConfigValue(name, value) {
const stateLabels = {
on: "开启",
off: "关闭",
internal: "内循环",
external: "外循环",
face: "面部出风",
feet: "脚部出风",
cinema: "观影",
rest: "小憩",
custom: "自定义",
};
if (typeof value === "string" && stateLabels[value]) return stateLabels[value];
if (name === "temperature_c") return `${value} ℃`;
if (name === "angle_deg") return `${value}°`;
if (name === "fan_level" || name === "level") return `${value} 档`;
if (name.endsWith("_pct")) return `${value}%`;
if (typeof value === "boolean") return value ? "开启" : "关闭";
if (value && typeof value === "object") return JSON.stringify(value);
return String(value ?? "--");
}
function configEntries(result) {
const configured = result.function_configs || {};
if (Object.keys(configured).length) return Object.entries(configured);
const single = result.function_config || result.preset_config;
const domain = single?.spec?.domain || "seat";
return single ? [[domain, single]] : [];
}
function planForDomain(result, domain) {
if (result.command_plans?.[domain]) return result.command_plans[domain];
return result.command_plan?.spec?.domain === domain ? result.command_plan : null;
}
function renderConfigCards(result) {
const container = $("#configCards");
container.replaceChildren();
const entries = configEntries(result);
const operations = entries.flatMap(([domain, config]) => (
(config?.spec?.operations || []).map((operation) => ({ domain, config, operation }))
));
if (!operations.length) {
const empty = makeConfigElement("div", "config-empty");
empty.innerHTML = '<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(({ 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, domain);
const allCommands = plan?.spec?.commands || [];
const matchedCommands = allCommands.filter((command) => !command.operation_ref || command.operation_ref === operation.id);
const command = matchedCommands[0] || null;
const hardwareBlock = makeConfigElement("section", `config-hardware-block ${command ? "mapped" : "unmapped"}`);
hardwareBlock.append(makeConfigElement("span", "config-block-label", "硬件映射"));
const route = makeConfigElement("div", "config-hardware-route");
const from = makeConfigElement("div", "config-route-node");
from.append(makeConfigElement("small", "", "逻辑配置"), makeConfigElement("strong", "", operation.id || "operation"));
const arrow = makeConfigElement("i", "config-route-arrow");
arrow.innerHTML = '<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");
footer.append(
makeConfigElement("span", "", `超时 ${operation.timeout_ms ?? "--"} ms`),
makeConfigElement("span", "", operation.failure_policy === "continue" ? "失败后继续" : "失败即中止"),
makeConfigElement("span", "", config.metadata?.labels?.execution_scope === "virtual_only" ? "仅虚拟执行" : "已通过配置校验"),
);
card.append(header, objectBlock, parametersBlock, hardwareBlock, footer);
container.append(card);
});
refreshIcons();
return operations.length;
}
function renderCode(result) {
const config = renderedConfig(result);
$("#configCode code").textContent = toYaml(config);
const configCardCount = renderConfigCards(result);
const configDomains = Object.keys(result.function_configs || {});
$("#configFileName").textContent = result.scene_config
? `scene.yaml + functions/{${configDomains.join(",")}}.yaml`
: result.preset_config
? "presets/seat.yaml"
: `functions/${configDomains[0] || "seat"}.yaml`;
$("#configNavMeta").textContent = `${configCardCount} 个控制动作`;
$("#planCode code").textContent = toYaml(renderedPlan(result));
const planDomains = Object.keys(result.command_plans || {});
$("#planFileName").textContent = planDomains.length > 1
? `command-plans/{${planDomains.join(",")}}.yaml`
: `command-plans/${planDomains[0] || "seat"}.yaml`;
const resultItems = result.execution_report?.spec?.results || [];
const telemetry = resultItems.find((item) => item.telemetry?.feedback_frame)?.telemetry
|| resultItems[0]?.telemetry;
const frames = resultItems
.map((item) => item.telemetry?.command_frame)
.filter(Boolean);
const summary = $("#frameSummary");
if (frames.length) {
const feedback = telemetry?.feedback_frame
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
: "NO DIRECT FEEDBACK";
const mode = 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";
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : "failed"}`;
const presetSave = Boolean(result.preset_config);
const physical = report.spec.mode === "hil";
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
let executionDetail = presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: physical
? `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`;
if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件";
else if (firmwareResult) executionDetail += ";板上统一固件身份已校验";
if (result.status === "partially_succeeded") {
executionDetail += ";未接入域请求已保留为 deferred request";
}
if (!reportSucceeded) {
const failure = report.spec.results.find((item) => item.status === "failed");
const failureTelemetry = failure?.telemetry || {};
executionDetail = failureTelemetry.error_type === "FirmwareProgrammingError"
? "固件身份验证或自动烧录失败,座椅控制帧未发送。"
: 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。"
: "真实台架未返回满足目标的物理反馈。";
}
summary.innerHTML = `<i data-lucide="${reportSucceeded ? (presetSave ? "bookmark-check" : "circle-check-big") : "circle-x"}"></i><div><strong>${reportSucceeded ? (presetSave ? "姿态预设已保存" : (physical ? "真实台架执行成功" : "虚拟执行成功")) : "真实台架执行失败"}</strong><span>${executionDetail}</span></div>`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${physical ? "HIL" : "DRY-RUN"}`
: "执行失败 · 查看回读";
$("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : "FAILED";
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : "failed"}`;
report.spec.results.forEach((item) => {
const row = document.createElement("div");
row.className = "result-item";
row.innerHTML = `<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");
frame.textContent = item.telemetry?.command_frame
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
: item.telemetry?.firmware_verified
? `${item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED"} · ${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
: "CONTROL PLANE · NO CAN";
row.append(text, frame);
list.append(row);
});
refreshIcons();
}
function renderResult(result) {
state.result = result;
const executionSucceeded = ["succeeded", "partially_succeeded"].includes(result.status);
const domains = Object.keys(result.function_configs || {});
const domainLabels = {
seat: "座椅",
climate: "空调",
roof: "天幕",
ambient_light: "氛围灯",
};
$("#resultTitle").textContent = executionSucceeded
? result.preset_config
? "座椅姿态预设已保存"
: result.scene_config
? `${domains.length} 域场景闭环完成`
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
: "请求未执行";
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
$("#requestMeta").textContent = `${result.request_id} / context:${conversationLabel} / turn:${result.conversation_turn} / ${result.provider_used || result.provider_requested}`;
$("#requestMeta").textContent += ` / ${result.execution_report?.spec?.mode || result.execution_mode || "dry_run"}`;
setRunStatus(
executionSucceeded ? "succeeded" : "failed",
result.status.toUpperCase(),
);
renderPipeline(result.trace_events || []);
renderDecision(result);
renderTrace(result.trace_events || []);
renderValidation(result.validation || []);
renderSeatState(result.actuator_state);
renderDomainState(result.domain_state);
updateTwinExplicitAngles(result);
renderSavedPresets(result);
renderCode(result);
renderExecution(result);
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
}
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
if (!utterance) {
showToast("请输入车舱控制请求");
return;
}
const button = $("#runButton");
button.disabled = true;
button.classList.add("loading");
button.innerHTML = '<i data-lucide="loader-circle"></i><span>处理中</span>';
refreshIcons();
setRunStatus("processing", "PROCESSING");
$("#resultTitle").textContent = "正在执行车舱编排";
startProgress();
const approvalTokenInput = $("#approvalToken");
const approvalToken = state.executionMode === "hardware"
? approvalTokenInput.value
: null;
if (approvalTokenInput) approvalTokenInput.value = "";
try {
const response = await fetch("/api/requests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
utterance,
provider: state.provider,
conversation_id: state.conversationId,
execution_mode: state.executionMode,
...(state.executionMode === "hardware"
? { approval_token: approvalToken }
: {}),
}),
});
const payload = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw new Error(payload.message || payload.error || `HTTP ${response.status}`);
renderResult(payload);
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
showToast(completed
? (payload.execution_report?.spec?.mode === "hil" ? "真实台架执行与反馈确认已完成" : "可执行动作的 dry-run 已完成")
: "请求未执行");
} catch (error) {
window.clearInterval(state.progressTimer);
resetPipeline();
setRunStatus("failed", "FAILED");
$("#resultTitle").textContent = "请求处理失败";
showToast(error.message);
} finally {
button.disabled = false;
button.classList.remove("loading");
button.innerHTML = `<i data-lucide="play"></i><span>${state.executionMode === "hardware" ? "授权并真实执行" : "生成并执行"}</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));
},
};
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);
$$(".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();
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
}
});