function createOpaqueId(prefix) {
if (globalThis.crypto?.randomUUID) {
return `${prefix}-${globalThis.crypto.randomUUID()}`;
}
if (globalThis.crypto?.getRandomValues) {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
const randomHex = [...bytes]
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
return `${prefix}-${randomHex}`;
}
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
}
function createConversationId() {
return createOpaqueId("conv");
}
function loadConversationId() {
const stored = sessionStorage.getItem("cognitive-agent.conversation-id");
if (stored?.startsWith("conv-")) return stored;
const conversationId = createConversationId();
sessionStorage.setItem("cognitive-agent.conversation-id", conversationId);
return conversationId;
}
function loadHardwareClientId() {
const key = "cognitive-agent.hardware-client-id";
const stored = localStorage.getItem(key);
if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
const clientId = createOpaqueId("hwc");
localStorage.setItem(key, clientId);
return clientId;
}
const state = {
provider: "openai",
reasoningEffort: localStorage.getItem("cognitive-agent.reasoning-effort") || "medium",
executionMode: "dry_run",
conversationId: loadConversationId(),
vehicleId: null,
runtime: null,
result: null,
vehicleStateSnapshot: null,
vehicleStateTurn: -1,
progressTimer: null,
seatTwin: null,
seatActuatorState: {},
seatFeedbackSource: "simulated",
seatFeedbackSources: { passenger: "simulated", driver: "unavailable" },
availableSeatAxes: null,
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
hardwareClientId: loadHardwareClientId(),
hardwareAccess: null,
hardwareAccessPoller: null,
physicalStatePoller: null,
physicalStateSyncPromise: null,
pendingModeProfileId: null,
modeRunTimer: null,
modeEventSequence: 0,
modeEventPoller: null,
modeEventStartedAt: Date.now(),
activeModeExecutions: new Set(),
renderedModeExecutions: new Set(),
modeDraftCommand: "",
lastSuccessfulUtterance: "",
};
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const apiRequest = (input, options) => window.CognitiveAgentNetwork.request(input, options);
// A four-axis hardware mode is executed serially. Each absolute move may wait
// up to 33 seconds, and firmware recovery or known-range homing can extend the
// request further. Keep the browser deadline above the backend's bounded HIL
// workflow so the UI does not report failure while the seat is still moving.
const HARDWARE_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 45 * 1000;
function requestTimeoutForExecutionMode(mode) {
return mode === "hardware" ? HARDWARE_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
}
function refreshIcons() {
if (window.lucide) {
window.lucide.createIcons({ attrs: { "aria-hidden": "true" } });
}
}
const TWIN_DEFAULT_POSITIONS = {
longitudinal: 50,
cushion_tilt: 50,
height: 50,
backrest_recline: 20,
legrest_height: 0,
legrest_extension: 0,
};
const TWIN_ANGLE_RANGES = {
backrest_recline: [95, 150],
legrest_height: [95, 167],
};
function initJointTwinLayout() {
const cabinBand = $(".cabin-band");
const twinBand = $(".twin-band");
const stateBand = $(".state-band");
const passengerCard = $('.seat-card[data-seat="passenger"]');
const driverCard = $('.seat-card[data-seat="driver"]');
const domainGrid = $(".domain-grid");
const coverage = $(".coverage-section");
if (!cabinBand || !twinBand || !stateBand || !passengerCard || !driverCard || !domainGrid || !coverage) return;
const shell = document.createElement("section");
shell.className = "joint-twin-shell";
shell.setAttribute("aria-labelledby", "jointTwinTitle");
const heading = document.createElement("header");
heading.className = "joint-twin-heading";
heading.innerHTML = `
VEHICLE DIGITAL TWIN · JOINT CONTROL
整车联合孪生总览
双通道前排座椅孪生:CH0 对应副驾座椅,CH1 对应主驾座椅;两侧状态相互独立。
副驾座椅
主驾座椅
空调
氛围灯
天幕
`;
cabinBand.before(shell);
shell.append(heading, twinBand, cabinBand);
[passengerCard, driverCard].forEach((card) => card.classList.add("joint-seat-panel", "domain-panel"));
passengerCard.dataset.deviceChannel = "ch0";
driverCard.dataset.deviceChannel = "ch1";
const seatFeedback = $("#seatFeedbackModeLabel");
if (seatFeedback) coverage.prepend(seatFeedback);
domainGrid.prepend(driverCard);
domainGrid.prepend(passengerCard);
const updateDomainGridScrollState = () => {
const isScrollable = domainGrid.scrollHeight > domainGrid.clientHeight + 2;
const isAtBottom = domainGrid.scrollTop + domainGrid.clientHeight >= domainGrid.scrollHeight - 2;
domainGrid.dataset.scrollable = String(isScrollable);
domainGrid.dataset.atBottom = String(!isScrollable || isAtBottom);
};
domainGrid.addEventListener("scroll", updateDomainGridScrollState, { passive: true });
if ("ResizeObserver" in window) {
const domainGridObserver = new ResizeObserver(updateDomainGridScrollState);
domainGridObserver.observe(domainGrid);
}
requestAnimationFrame(updateDomainGridScrollState);
coverage.classList.add("joint-hardware-boundary");
shell.append(coverage);
stateBand.remove();
}
function twinAngleForPosition(axis, positionPct) {
const range = TWIN_ANGLE_RANGES[axis];
if (!range) return null;
return range[0] + (range[1] - range[0]) * Number(positionPct) / 100;
}
function isEstimatedMotion(device, axis) {
const motion = device?.position?.[axis];
return Boolean(motion?.position_estimated || motion?.position_valid === false);
}
function twinPostureAngles(device) {
const position = (axis, fallback) => Number(
device?.position?.[axis]?.position_pct ?? fallback,
);
const included = (axis, fallback) => Number(
device?.position?.[axis]?.angle_deg
?? twinAngleForPosition(axis, position(axis, fallback)),
);
const cushion = position("cushion_tilt", 50) * 0.09;
return {
cushion_tilt: cushion,
backrest_recline: included("backrest_recline", 20)
- TWIN_ANGLE_RANGES.backrest_recline[0],
legrest_height: included("legrest_height", 0)
- TWIN_ANGLE_RANGES.legrest_height[0],
};
}
function makeDynamicLine(THREE, parent, pointCount, material, segments = false) {
const positions = new Float32Array(pointCount * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const line = segments
? new THREE.LineSegments(geometry, material)
: new THREE.Line(geometry, material);
line.frustumCulled = false;
line.renderOrder = 20;
parent.add(line);
return { line, positions, attribute: geometry.getAttribute("position") };
}
function createTwinAngleGuide(THREE, parent, color) {
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.95,
depthTest: false,
});
const group = new THREE.Group();
parent.add(group);
const dotMaterial = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.96,
depthTest: false,
});
const dotGeometry = new THREE.SphereGeometry(0.022, 8, 8);
const dots = Array.from({ length: 13 }, () => {
const dot = new THREE.Mesh(dotGeometry, dotMaterial);
dot.renderOrder = 21;
group.add(dot);
return dot;
});
dots[0].scale.setScalar(1.6);
dots[dots.length - 1].scale.setScalar(1.6);
return {
group,
arc: makeDynamicLine(THREE, group, 49, material),
rays: makeDynamicLine(THREE, group, 4, material, true),
dots,
};
}
function updateTwinAngleGuide(guide, x, pivotY, pivotZ, startDeg, sweepDeg, radius) {
for (let index = 0; index < 49; index += 1) {
const angle = (startDeg + sweepDeg * index / 48) * Math.PI / 180;
const offset = index * 3;
guide.arc.positions[offset] = x;
guide.arc.positions[offset + 1] = pivotY + Math.sin(angle) * radius;
guide.arc.positions[offset + 2] = pivotZ + Math.cos(angle) * radius;
}
const endpoints = [
[x, pivotY, pivotZ],
[guide.arc.positions[0], guide.arc.positions[1], guide.arc.positions[2]],
[x, pivotY, pivotZ],
[
guide.arc.positions[guide.arc.positions.length - 3],
guide.arc.positions[guide.arc.positions.length - 2],
guide.arc.positions[guide.arc.positions.length - 1],
],
];
endpoints.flat().forEach((value, index) => {
guide.rays.positions[index] = value;
});
guide.dots.forEach((dot, index) => {
const source = index * 4 * 3;
dot.position.set(
guide.arc.positions[source],
guide.arc.positions[source + 1],
guide.arc.positions[source + 2],
);
});
guide.arc.attribute.needsUpdate = true;
guide.rays.attribute.needsUpdate = true;
}
function makeTwinBox(THREE, parent, size, position, material, edgeMaterial) {
const geometry = new THREE.BoxGeometry(...size);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 32), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeCabinBeam(THREE, parent, start, end, radius, material) {
const from = new THREE.Vector3(...start);
const to = new THREE.Vector3(...end);
const direction = to.clone().sub(from);
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, direction.length(), 12),
material,
);
beam.position.copy(from).add(to).multiplyScalar(0.5);
beam.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.normalize(),
);
beam.castShadow = true;
parent.add(beam);
return beam;
}
function createTwinAirflowField(THREE, parent) {
const count = 56;
const positions = new Float32Array(count * 3);
const seeds = Array.from({ length: count }, (_, index) => (
(index * 0.61803398875) % 1
));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x55bdec,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 24;
parent.add(points);
return {
points,
positions,
seeds,
attribute: geometry.getAttribute("position"),
material,
level: 0,
};
}
function makeVehicleProfileShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-3.72, 0.2);
shape.lineTo(-3.72, 0.92);
shape.quadraticCurveTo(-3.6, 1.28, -3.12, 1.47);
shape.quadraticCurveTo(-2.42, 1.51, -1.52, 1.45);
shape.lineTo(1.86, 1.45);
shape.quadraticCurveTo(2.06, 1.47, 2.22, 1.52);
shape.quadraticCurveTo(2.88, 1.52, 3.48, 1.28);
shape.quadraticCurveTo(3.76, 1.12, 3.74, 0.72);
shape.lineTo(3.68, 0.24);
shape.lineTo(3.12, 0.18);
shape.bezierCurveTo(3.02, 0.76, 2.8, 1.3, 2.35, 1.3);
shape.bezierCurveTo(1.9, 1.3, 1.68, 0.76, 1.58, 0.18);
shape.lineTo(-1.48, 0.18);
shape.bezierCurveTo(-1.58, 0.76, -1.8, 1.3, -2.25, 1.3);
shape.bezierCurveTo(-2.7, 1.3, -2.92, 0.76, -3.02, 0.18);
shape.lineTo(-3.72, 0.2);
shape.closePath();
return shape;
}
function makeVehicleGreenhouseShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.62, 1.31);
shape.quadraticCurveTo(-2.28, 1.8, -1.82, 2.42);
shape.quadraticCurveTo(-0.48, 2.68, 0.92, 2.5);
shape.quadraticCurveTo(1.58, 2.4, 2.18, 1.31);
shape.closePath();
return shape;
}
function makeVehicleSideGlassShape(THREE) {
const shape = new THREE.Shape();
shape.moveTo(-2.58, 1.32);
shape.lineTo(-1.79, 2.36);
shape.quadraticCurveTo(-0.5, 2.56, 1.03, 2.42);
shape.lineTo(1.82, 1.32);
shape.closePath();
return shape;
}
function makeRoundedPanelShape(THREE, width, length, radius) {
const shape = new THREE.Shape();
const halfWidth = width / 2;
const halfLength = length / 2;
shape.moveTo(-halfWidth + radius, -halfLength);
shape.lineTo(halfWidth - radius, -halfLength);
shape.quadraticCurveTo(halfWidth, -halfLength, halfWidth, -halfLength + radius);
shape.lineTo(halfWidth, halfLength - radius);
shape.quadraticCurveTo(halfWidth, halfLength, halfWidth - radius, halfLength);
shape.lineTo(-halfWidth + radius, halfLength);
shape.quadraticCurveTo(-halfWidth, halfLength, -halfWidth, halfLength - radius);
shape.lineTo(-halfWidth, -halfLength + radius);
shape.quadraticCurveTo(-halfWidth, -halfLength, -halfWidth + radius, -halfLength);
shape.closePath();
return shape;
}
function createTwinRearBench(THREE) {
const root = new THREE.Group();
root.position.set(0, 0.12, -1.72);
root.userData.twinRole = "fixed_rear_bench";
const leather = new THREE.MeshPhysicalMaterial({
color: 0x35484e,
roughness: 0.58,
metalness: 0.01,
clearcoat: 0.12,
clearcoatRoughness: 0.75,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x829497,
roughness: 0.72,
clearcoat: 0.05,
});
const structure = new THREE.MeshStandardMaterial({
color: 0x26343a,
roughness: 0.68,
metalness: 0.25,
});
const buckleMaterial = new THREE.MeshStandardMaterial({
color: 0xb64b43,
roughness: 0.5,
});
const edge = new THREE.LineBasicMaterial({
color: 0x19272b,
transparent: true,
opacity: 0.42,
});
makeTwinBox(THREE, root, [2.5, 0.18, 0.72], [0, 0.24, 0.08], structure, edge);
const cushion = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 1.82, 7, 24), leather);
cushion.rotation.z = Math.PI / 2;
cushion.scale.set(0.92, 1, 1.23);
cushion.position.set(0, 0.46, 0.08);
cushion.castShadow = true;
cushion.receiveShadow = true;
root.add(cushion);
const backrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.3, 1.78, 7, 24), leather);
backrest.rotation.z = Math.PI / 2;
backrest.scale.set(1.46, 1, 0.43);
backrest.position.set(0, 1.04, -0.68);
backrest.castShadow = true;
root.add(backrest);
[-0.79, 0, 0.79].forEach((x) => {
const cushionInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.13, 0.42, 6, 18),
insert,
);
cushionInsert.rotation.z = Math.PI / 2;
cushionInsert.scale.set(0.52, 1, 2.05);
cushionInsert.position.set(x, 0.71, 0.08);
cushionInsert.castShadow = true;
root.add(cushionInsert);
const backInsert = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.42, 6, 18),
insert,
);
backInsert.scale.set(1.65, 1, 0.42);
backInsert.position.set(x, 1.05, -0.535);
backInsert.castShadow = true;
root.add(backInsert);
makeCabinBeam(THREE, root, [x - 0.11, 1.43, -0.7], [x - 0.11, 1.55, -0.7], 0.018, structure);
makeCabinBeam(THREE, root, [x + 0.11, 1.43, -0.7], [x + 0.11, 1.55, -0.7], 0.018, structure);
const headrest = new THREE.Mesh(
new THREE.CapsuleGeometry(0.18, 0.16, 7, 18),
leather,
);
headrest.scale.set(1.25, 1, 0.55);
headrest.position.set(x, 1.62, -0.7);
headrest.castShadow = true;
root.add(headrest);
});
[-0.4, 0.4].forEach((x) => {
makeTwinBox(THREE, root, [0.09, 0.075, 0.12], [x, 0.73, -0.04], buckleMaterial, edge);
});
return root;
}
function createTwinCabin(THREE, scene) {
const cabin = new THREE.Group();
const trim = new THREE.MeshStandardMaterial({
color: 0x25343a,
roughness: 0.6,
metalness: 0.2,
transparent: true,
opacity: 0.7,
depthWrite: false,
});
const softTrim = new THREE.MeshStandardMaterial({
color: 0x506167,
roughness: 0.8,
transparent: true,
opacity: 0.5,
depthWrite: false,
});
const cockpit = new THREE.MeshStandardMaterial({
color: 0x243238,
roughness: 0.6,
metalness: 0.18,
});
const consoleTop = new THREE.MeshStandardMaterial({
color: 0x5c6c70,
roughness: 0.66,
metalness: 0.12,
});
const accent = new THREE.MeshStandardMaterial({
color: 0x26364c,
emissive: 0x78efff,
emissiveIntensity: 0.18,
roughness: 0.22,
metalness: 0.18,
depthTest: false,
});
const ambientHousingMaterial = new THREE.MeshStandardMaterial({
color: 0x17242c,
roughness: 0.38,
metalness: 0.56,
emissive: 0x08141a,
emissiveIntensity: 0.18,
});
const ambientMeshes = [];
const invisibleEdge = new THREE.LineBasicMaterial({ transparent: true, opacity: 0 });
const shellMaterial = new THREE.MeshPhysicalMaterial({
color: 0x84adb7,
roughness: 0.18,
metalness: 0.1,
transmission: 0.48,
transparent: true,
opacity: 0.17,
depthWrite: false,
side: THREE.DoubleSide,
clearcoat: 0.9,
clearcoatRoughness: 0.18,
});
const glassMaterial = new THREE.MeshPhysicalMaterial({
color: 0x6f9eae,
roughness: 0.06,
transmission: 0.86,
transparent: true,
opacity: 0.22,
depthWrite: false,
side: THREE.DoubleSide,
});
const shellEdge = new THREE.LineBasicMaterial({
color: 0x456975,
transparent: true,
opacity: 0.56,
});
const roofEntityEdge = new THREE.LineBasicMaterial({
color: 0x086861,
transparent: true,
opacity: 0.98,
depthTest: true,
});
const ventEntityEdge = new THREE.LineBasicMaterial({
color: 0x9eeaff,
transparent: true,
opacity: 1,
depthTest: false,
});
const bodyShell = makeTwinPanel(
THREE,
cabin,
makeVehicleProfileShape(THREE),
3.16,
[0, 0, 0],
[0, -Math.PI / 2, 0],
shellMaterial,
shellEdge,
);
bodyShell.castShadow = false;
bodyShell.receiveShadow = false;
bodyShell.renderOrder = 2;
const greenhouseMaterial = glassMaterial.clone();
greenhouseMaterial.color.setHex(0x769ead);
greenhouseMaterial.opacity = 0.12;
const greenhouse = makeTwinPanel(
THREE,
cabin,
makeVehicleGreenhouseShape(THREE),
2.64,
[0, 0, 0],
[0, -Math.PI / 2, 0],
greenhouseMaterial,
shellEdge,
);
greenhouse.castShadow = false;
greenhouse.renderOrder = 3;
const bodyPanelMaterial = shellMaterial.clone();
bodyPanelMaterial.color.setHex(0x6f9da8);
bodyPanelMaterial.transmission = 0.34;
bodyPanelMaterial.opacity = 0.3;
const exteriorTrim = new THREE.MeshStandardMaterial({
color: 0x1e2a2f,
roughness: 0.52,
metalness: 0.3,
});
const hood = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 2.68, 1.44, 0.18),
0.05,
[0, 1.43, 2.88],
[-Math.PI / 2 + 0.1, 0, 0],
bodyPanelMaterial,
shellEdge,
);
hood.castShadow = false;
hood.renderOrder = 3;
makeTwinBox(THREE, cabin, [2.82, 0.42, 0.14], [0, 0.57, 3.57], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [2.82, 0.38, 0.14], [0, 0.54, -3.55], bodyPanelMaterial, invisibleEdge);
makeTwinBox(THREE, cabin, [1.1, 0.18, 0.055], [0, 0.61, 3.655], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.68, 0.055, 0.035], [0, 0.82, -3.635], exteriorTrim, invisibleEdge);
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.12, 0.2, 2.88], [side * 1.5, 0.34, 0.02], bodyPanelMaterial, shellEdge);
makeCabinBeam(THREE, cabin, [side * 1.615, 1.3, 1.82], [side * 1.615, 1.3, -2.56], 0.012, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.4, 0.18], [side * 1.62, 1.29, 0.18], 0.01, exteriorTrim);
makeCabinBeam(THREE, cabin, [side * 1.62, 0.38, -1.48], [side * 1.62, 1.29, -1.48], 0.01, exteriorTrim);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, 0.58], exteriorTrim, invisibleEdge);
makeTwinBox(THREE, cabin, [0.035, 0.035, 0.26], [side * 1.65, 1.18, -1.2], exteriorTrim, invisibleEdge);
const mirror = new THREE.Mesh(new THREE.SphereGeometry(0.18, 18, 12), bodyPanelMaterial);
mirror.scale.set(0.56, 0.68, 1.24);
mirror.position.set(side * 1.76, 1.48, 1.3);
mirror.castShadow = true;
cabin.add(mirror);
makeCabinBeam(THREE, cabin, [side * 1.52, 1.4, 1.31], [side * 1.68, 1.46, 1.3], 0.025, exteriorTrim);
}
[-1, 1].forEach((side) => {
const sideGlass = makeTwinPanel(
THREE,
cabin,
makeVehicleSideGlassShape(THREE),
0.025,
[side * 1.335, 0, 0],
[0, -Math.PI / 2, 0],
glassMaterial,
shellEdge,
);
sideGlass.castShadow = false;
sideGlass.renderOrder = 4;
});
const windshield = makeTwinBox(
THREE,
cabin,
[2.48, 1.43, 0.035],
[0, 1.88, 1.57],
glassMaterial,
shellEdge,
);
windshield.rotation.x = -0.49;
windshield.castShadow = false;
windshield.renderOrder = 4;
const rearGlass = makeTwinBox(
THREE,
cabin,
[2.38, 1.28, 0.035],
[0, 1.88, -2.26],
glassMaterial,
shellEdge,
);
rearGlass.rotation.x = 0.6;
rearGlass.castShadow = false;
rearGlass.renderOrder = 4;
const roofGlassMaterial = glassMaterial.clone();
roofGlassMaterial.color.setHex(0x258f89);
roofGlassMaterial.emissive.setHex(0x124c49);
roofGlassMaterial.emissiveIntensity = 0.38;
roofGlassMaterial.transmission = 0;
roofGlassMaterial.transparent = true;
roofGlassMaterial.opacity = 0.52;
roofGlassMaterial.depthWrite = false;
const roofGlass = makeTwinPanel(
THREE,
cabin,
makeRoundedPanelShape(THREE, 1.08, 1.96, 0.16),
0.038,
[0, 2.43, -0.61],
[-Math.PI / 2, 0, 0],
roofGlassMaterial,
roofEntityEdge,
);
roofGlass.castShadow = false;
roofGlass.renderOrder = 7;
const roofFrameMaterial = new THREE.MeshStandardMaterial({
color: 0x086861,
emissive: 0x124c49,
emissiveIntensity: 0.38,
roughness: 0.24,
metalness: 0.62,
transparent: true,
opacity: 0.88,
depthWrite: false,
depthTest: true,
});
const roofMeshes = [roofGlass];
[
[[1.22, 0.055, 0.075], [0, 2.455, 0.405]],
[[1.22, 0.055, 0.075], [0, 2.455, -1.625]],
[[0.075, 0.055, 1.96], [-0.59, 2.455, -0.61]],
[[0.075, 0.055, 1.96], [0.59, 2.455, -0.61]],
].forEach(([size, position]) => {
roofMeshes.push(makeTwinBox(
THREE,
cabin,
size,
position,
roofFrameMaterial,
invisibleEdge,
));
});
const roofShadeMaterial = new THREE.MeshStandardMaterial({
color: 0x5f6d71,
roughness: 0.86,
transparent: true,
opacity: 0.06,
depthWrite: false,
});
const roofShade = makeTwinBox(
THREE,
cabin,
[1.02, 0.025, 1.88],
[0, 2.39, -1.55],
roofShadeMaterial,
invisibleEdge,
);
roofShade.scale.z = 0.015;
roofShade.renderOrder = 6;
for (const side of [-1, 1]) {
makeCabinBeam(THREE, cabin, [side * 1.47, 1.25, 1.9], [side * 1.25, 2.51, 1.19], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.5, 1.25, -2.55], [side * 1.25, 2.48, -1.78], 0.05, trim);
makeCabinBeam(THREE, cabin, [side * 1.56, 1.25, -0.32], [side * 1.34, 2.54, -0.32], 0.045, trim);
makeCabinBeam(THREE, cabin, [side * 1.24, 2.57, 1.27], [side * 1.24, 2.57, -1.84], 0.045, trim);
}
makeCabinBeam(THREE, cabin, [-1.23, 2.58, 1.3], [1.23, 2.58, 1.3], 0.045, trim);
makeCabinBeam(THREE, cabin, [-1.23, 2.58, -1.9], [1.23, 2.58, -1.9], 0.045, trim);
const cabinFloor = makeTwinBox(
THREE,
cabin,
[2.86, 0.08, 5.45],
[0, 0.13, -0.05],
new THREE.MeshStandardMaterial({ color: 0x2b393e, roughness: 0.92, transparent: true, opacity: 0.5 }),
invisibleEdge,
);
cabinFloor.receiveShadow = true;
for (const side of [-1, 1]) {
makeTwinBox(THREE, cabin, [0.065, 0.62, 3.34], [side * 1.46, 0.67, -0.18], softTrim, invisibleEdge);
makeCabinBeam(THREE, cabin, [side * 1.415, 1.02, 1.3], [side * 1.415, 1.02, -1.76], 0.012, trim);
}
makeTwinBox(THREE, cabin, [0.42, 0.36, 1.55], [0, 0.4, 0.42], cockpit, invisibleEdge);
makeTwinBox(THREE, cabin, [0.38, 0.035, 1.28], [0, 0.6, 0.4], consoleTop, invisibleEdge);
const dashboard = new THREE.Mesh(new THREE.CapsuleGeometry(0.13, 2.55, 7, 28), softTrim);
dashboard.rotation.z = Math.PI / 2;
dashboard.position.set(0, 1.38, 1.63);
dashboard.scale.z = 1.5;
dashboard.castShadow = true;
cabin.add(dashboard);
const gloveboxLightHousing = makeTwinBox(
THREE,
cabin,
[1.18, 0.135, 0.075],
[-0.75, 1.265, 1.43],
ambientHousingMaterial,
invisibleEdge,
);
ambientMeshes.push(gloveboxLightHousing);
for (let index = 0; index < 9; index += 1) {
const lightSegment = makeTwinBox(
THREE,
cabin,
[0.09, 0.042, 0.028],
[-1.21 + index * 0.115, 1.315, 1.385],
accent,
invisibleEdge,
);
lightSegment.userData.twinRole = "passenger_glovebox_ambient_light";
ambientMeshes.push(lightSegment);
}
makeTwinBox(THREE, cabin, [0.54, 0.3, 0.055], [0.82, 1.62, 1.49], cockpit, invisibleEdge);
const steeringWheel = new THREE.Group();
const wheel = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.036, 10, 36), cockpit);
steeringWheel.add(wheel);
makeTwinBox(THREE, steeringWheel, [0.035, 0.22, 0.028], [0, -0.1, 0], cockpit, invisibleEdge);
makeTwinBox(THREE, steeringWheel, [0.23, 0.035, 0.028], [0, -0.02, 0], cockpit, invisibleEdge);
steeringWheel.position.set(0.82, 1.48, 1.24);
steeringWheel.rotation.x = -0.34;
cabin.add(steeringWheel);
makeCabinBeam(THREE, cabin, [0.82, 1.43, 1.27], [0.82, 1.34, 1.57], 0.024, cockpit);
const tireMaterial = new THREE.MeshStandardMaterial({
color: 0x172126,
roughness: 0.92,
metalness: 0.05,
});
const rimMaterial = new THREE.MeshStandardMaterial({
color: 0x798a8f,
roughness: 0.38,
metalness: 0.72,
});
[-1, 1].forEach((side) => {
[-2.25, 2.35].forEach((z) => {
const tire = new THREE.Mesh(
new THREE.TorusGeometry(0.53, 0.15, 14, 32),
tireMaterial,
);
tire.position.set(side * 1.49, 0.58, z);
tire.rotation.y = Math.PI / 2;
tire.castShadow = true;
cabin.add(tire);
const rim = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.34, 0.11, 24), rimMaterial);
rim.position.copy(tire.position);
rim.rotation.z = Math.PI / 2;
cabin.add(rim);
const archLip = new THREE.Mesh(
new THREE.TorusGeometry(0.69, 0.035, 8, 36, Math.PI),
bodyPanelMaterial,
);
archLip.position.set(side * 1.615, 0.58, z);
archLip.rotation.y = Math.PI / 2;
archLip.castShadow = true;
cabin.add(archLip);
const spokeX = side * 1.555;
for (let spoke = 0; spoke < 5; spoke += 1) {
const angle = spoke * Math.PI * 2 / 5;
makeCabinBeam(
THREE,
cabin,
[spokeX, 0.58, z],
[spokeX, 0.58 + Math.cos(angle) * 0.27, z + Math.sin(angle) * 0.27],
0.022,
rimMaterial,
);
}
const hub = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 8), exteriorTrim);
hub.position.set(spokeX, 0.58, z);
hub.scale.x = 0.45;
cabin.add(hub);
});
});
const lampMaterial = new THREE.MeshStandardMaterial({
color: 0x5d7076,
emissive: 0xb9ecff,
emissiveIntensity: 0.08,
roughness: 0.34,
});
const rearLampMaterial = new THREE.MeshStandardMaterial({
color: 0x697b80,
emissive: 0x000000,
emissiveIntensity: 0,
roughness: 0.56,
});
const headlampMeshes = [];
for (const side of [-1, 1]) {
const headlamp = new THREE.Mesh(new THREE.SphereGeometry(0.16, 18, 10), lampMaterial);
headlamp.scale.set(2.6, 0.75, 0.24);
headlamp.position.set(side * 1.03, 1.05, 3.61);
cabin.add(headlamp);
headlampMeshes.push(headlamp);
const rearLamp = new THREE.Mesh(new THREE.SphereGeometry(0.15, 18, 10), rearLampMaterial);
rearLamp.scale.set(2.35, 0.7, 0.22);
rearLamp.position.set(side * 1.08, 1.08, -3.57);
cabin.add(rearLamp);
}
makeTwinBox(THREE, cabin, [1.08, 0.22, 0.035], [0, 0.72, 3.69], cockpit, invisibleEdge);
const ventMaterials = [];
const ventMeshes = [];
const ventCoreMaterial = new THREE.MeshStandardMaterial({
color: 0x07141c,
roughness: 0.5,
metalness: 0.34,
});
const ventBladeMaterial = new THREE.MeshStandardMaterial({
color: 0xd8f2f7,
emissive: 0x62dfff,
emissiveIntensity: 0.48,
roughness: 0.2,
metalness: 0.82,
depthTest: false,
});
[-1.14, -0.34, 0.34, 1.14].forEach((x) => {
const ventMaterial = new THREE.MeshStandardMaterial({
color: 0x176c94,
roughness: 0.24,
metalness: 0.55,
emissive: 0x2ccff3,
emissiveIntensity: 0.62,
});
ventMaterials.push(ventMaterial);
const vent = makeTwinBox(
THREE,
cabin,
[Math.abs(x) > 1 ? 0.47 : 0.44, 0.2, 0.135],
[x, 1.38, 1.405],
ventMaterial,
ventEntityEdge,
);
vent.userData.twinRole = "dashboard_hvac_outlet";
ventMeshes.push(vent);
const coreWidth = Math.abs(x) > 1 ? 0.365 : 0.34;
makeTwinBox(
THREE,
vent,
[coreWidth, 0.12, 0.145],
[0, 0, 0],
ventCoreMaterial,
invisibleEdge,
);
for (const bladeY of [-0.038, 0, 0.038]) {
makeTwinBox(
THREE,
vent,
[coreWidth * 0.9, 0.014, 0.025],
[0, bladeY, -0.083],
ventBladeMaterial,
invisibleEdge,
);
}
});
const rearVentMaterial = new THREE.MeshStandardMaterial({
color: 0x203741,
roughness: 0.34,
metalness: 0.42,
emissive: 0x2d91b6,
emissiveIntensity: 0.3,
});
ventMaterials.push(rearVentMaterial);
[-0.1, 0.1].forEach((x) => {
const vent = makeTwinBox(
THREE,
cabin,
[0.18, 0.13, 0.065],
[x, 0.48, -0.375],
rearVentMaterial,
ventEntityEdge,
);
ventMeshes.push(vent);
});
const airflow = createTwinAirflowField(THREE, cabin);
const ambientLights = [
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
new THREE.PointLight(0x74d9ff, 0, 2.2, 2),
];
ambientLights[0].position.set(-1.06, 1.22, 1.08);
ambientLights[1].position.set(-0.48, 1.22, 1.08);
ambientLights.forEach((light) => cabin.add(light));
const fallbackExteriorMaterials = new Set([
shellMaterial,
greenhouseMaterial,
bodyPanelMaterial,
exteriorTrim,
glassMaterial,
tireMaterial,
lampMaterial,
rearLampMaterial,
]);
const fallbackExteriorObjects = [];
const fallbackInteriorMaterials = new Set([trim, softTrim, cockpit, consoleTop]);
const fallbackInteriorObjects = [];
const worldPosition = new THREE.Vector3();
cabin.updateMatrixWorld(true);
cabin.traverse((object) => {
if (!object.isMesh) return;
const isFallbackMaterial = fallbackExteriorMaterials.has(object.material);
object.getWorldPosition(worldPosition);
const isWheelDetail = (
object.material === rimMaterial
&& Math.abs(worldPosition.z) > 1.8
);
if (isFallbackMaterial || isWheelDetail) fallbackExteriorObjects.push(object);
if (fallbackInteriorMaterials.has(object.material) || object === cabinFloor) {
fallbackInteriorObjects.push(object);
}
});
scene.add(cabin);
return {
root: cabin,
fallbackExteriorObjects,
fallbackInteriorObjects,
shellMaterial,
glassMaterial,
referenceWindowPanes: {
front_left: [],
front_right: [],
rear_left: [],
rear_right: [],
},
roofGlass,
roofMeshes,
roofGlassMaterial,
roofFrameMaterial,
roofEdgeMaterial: roofEntityEdge,
roofShade,
roofShadeMaterial,
ambientMaterial: accent,
ambientMeshes,
ambientLights,
headlampMaterial: lampMaterial,
headlampMeshes,
referenceFrontLampMaterials: [],
referenceFrontLampMeshes: [],
referenceFrontLampHighlights: [],
referenceFrontLampLensOutlines: [],
ventMaterials,
ventMeshes,
airflow,
colors: {
ambientTarget: new THREE.Color(0x74d9ff),
ambientIdle: new THREE.Color(0x17323a),
climateTarget: new THREE.Color(0x55bdec),
roofClear: new THREE.Color(0x1f68b6),
roofDark: new THREE.Color(0x071b3e),
},
targets: {
ambientPower: 0,
ambientBrightness: 0,
climateLevel: 0,
climateTemperature: 24,
roofTransmittance: 100,
roofShadePosition: 0,
windowPositions: {
front_left: 0,
front_right: 0,
rear_left: 0,
rear_right: 0,
},
lowBeam: 0,
highBeam: 0,
fogLight: 0,
turnSignal: 0,
},
current: {
ambientBrightness: 0,
climateLevel: 0,
roofTransmittance: 100,
roofShadePosition: 0,
windowPositions: {
front_left: 0,
front_right: 0,
rear_left: 0,
rear_right: 0,
},
lowBeam: 0,
highBeam: 0,
fogLight: 0,
turnSignal: 0,
},
layout: {
roofShadeCenterZ: -0.61,
roofShadeHalfLength: 0.94,
},
};
}
const TWIN_THEME_PALETTES = {
user: {
glass: 0x258f89,
glassEmissive: 0x124c49,
glassEdge: 0x086861,
glassGlow: 0x49e1d6,
roofClear: 0x4aa9a3,
roofDark: 0x0a5552,
lamp: 0x087b74,
lampEmissive: 0x16aaa1,
lampEdge: 0x096d67,
},
admin: {
glass: 0x477bc4,
glassEmissive: 0x1f427c,
glassEdge: 0x234f91,
glassGlow: 0x86b3ff,
roofClear: 0x7299d5,
roofDark: 0x1d3c72,
lamp: 0x315eaa,
lampEmissive: 0x5688dc,
lampEdge: 0x244a86,
},
};
function currentTwinThemeRole() {
return document.body.dataset.demoRole === "admin"
|| sessionStorage.getItem("cognitive-agent.demo-role") === "admin"
? "admin"
: "user";
}
function applyTwinVehicleTheme(systems, role = currentTwinThemeRole()) {
if (!systems) return;
const themeRole = role === "admin" ? "admin" : "user";
const palette = TWIN_THEME_PALETTES[themeRole];
systems.themeRole = themeRole;
systems.themePalette = palette;
systems.colors.roofClear.setHex(palette.roofClear);
systems.colors.roofDark.setHex(palette.roofDark);
if (systems.roofGlassMaterial.color) systems.roofGlassMaterial.color.setHex(palette.glass);
if (systems.roofGlassMaterial.emissive) {
systems.roofGlassMaterial.emissive.setHex(palette.glassEmissive);
}
if (systems.roofFrameMaterial?.color) systems.roofFrameMaterial.color.setHex(palette.glassEdge);
if (systems.roofFrameMaterial?.emissive) {
systems.roofFrameMaterial.emissive.setHex(palette.glassEmissive);
}
if (systems.roofEdgeMaterial?.color) systems.roofEdgeMaterial.color.setHex(palette.glassEdge);
Object.values(systems.referenceWindowPanes).flat().forEach((pane) => {
(pane.materials || []).forEach((material) => {
if (material.color) material.color.setHex(palette.glass);
if (material.emissive) material.emissive.setHex(palette.glassEmissive);
});
if (pane.outline?.material?.color) pane.outline.material.color.setHex(palette.glassEdge);
});
systems.referenceFrontLampMaterials.forEach((material) => {
if (material.color) material.color.setHex(palette.lamp);
if (material.emissive) material.emissive.setHex(palette.lampEmissive);
});
systems.referenceFrontLampHighlights.forEach(({ material, isHalo }) => {
if (material.color) material.color.setHex(isHalo ? palette.glassGlow : palette.lamp);
});
systems.referenceFrontLampLensOutlines.forEach(({ mesh, outline }) => {
if (mesh.material?.color) mesh.material.color.setHex(palette.lamp);
if (outline?.material?.color) outline.material.color.setHex(palette.lampEdge);
});
}
function updateTwinVehicleVisual(systems, timestamp) {
if (!systems) return;
const ease = (current, target, factor = 0.08) => (
Math.abs(target - current) < 0.002 ? target : current + (target - current) * factor
);
systems.current.ambientBrightness = ease(
systems.current.ambientBrightness,
systems.targets.ambientPower ? systems.targets.ambientBrightness : 0,
);
systems.current.climateLevel = ease(
systems.current.climateLevel,
systems.targets.climateLevel,
);
systems.current.roofTransmittance = ease(
systems.current.roofTransmittance,
systems.targets.roofTransmittance,
);
systems.current.roofShadePosition = ease(
systems.current.roofShadePosition,
systems.targets.roofShadePosition,
);
Object.keys(systems.current.windowPositions).forEach((key) => {
systems.current.windowPositions[key] = ease(
systems.current.windowPositions[key],
systems.targets.windowPositions[key],
0.1,
);
});
["lowBeam", "highBeam", "fogLight", "turnSignal"].forEach((key) => {
systems.current[key] = ease(systems.current[key], systems.targets[key], 0.12);
});
const ambientStrength = systems.current.ambientBrightness / 100;
const ambientDisplayColor = systems.colors.ambientIdle.clone().lerp(
systems.colors.ambientTarget,
0.34 + ambientStrength * 0.66,
);
systems.ambientMaterial.color.lerp(ambientDisplayColor, 0.1);
systems.ambientMaterial.emissive.lerp(systems.colors.ambientTarget, 0.1);
systems.ambientMaterial.emissiveIntensity = 0.28 + ambientStrength * 2.15;
systems.ambientMaterial.transparent = false;
systems.ambientMaterial.opacity = 1;
systems.ambientMaterial.depthWrite = true;
systems.ambientLights.forEach((light) => {
light.color.lerp(systems.colors.ambientTarget, 0.1);
light.intensity = ambientStrength * 2.4;
});
const roofRatio = systems.current.roofTransmittance / 100;
const roofColor = systems.colors.roofDark.clone().lerp(systems.colors.roofClear, roofRatio);
systems.roofGlassMaterial.color.lerp(roofColor, 0.08);
systems.roofGlassMaterial.opacity = 0.52 + (1 - roofRatio) * 0.18;
const shadeCoverage = Math.max(0.015, systems.current.roofShadePosition / 100);
systems.roofShade.scale.z = shadeCoverage;
systems.roofShade.position.z = systems.layout.roofShadeCenterZ
- (1 - shadeCoverage) * systems.layout.roofShadeHalfLength;
systems.roofShadeMaterial.opacity = 0.08 + shadeCoverage * 0.82;
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
const openingRatio = systems.current.windowPositions[key] / 100;
panes.forEach((pane) => {
pane.mesh.position.lerpVectors(pane.closedPosition, pane.openPosition, openingRatio);
});
});
const mainLampStrength = Math.max(
systems.current.lowBeam * 0.62,
systems.current.highBeam,
);
const isHighBeam = systems.current.highBeam > systems.current.lowBeam * 0.62;
const fogStrength = systems.current.fogLight;
const turnPulse = systems.targets.turnSignal
? 0.2 + 0.8 * (Math.sin(timestamp * 0.008) > 0 ? 1 : 0)
: systems.current.turnSignal;
const lampStrength = Math.max(mainLampStrength, fogStrength * 0.72, turnPulse);
const lampColor = turnPulse > Math.max(mainLampStrength, fogStrength * 0.72)
? 0xffa627
: (fogStrength * 0.72 > mainLampStrength ? 0xfff0b3 : (isHighBeam ? 0xeafaff : 0xcdefff));
const updateFrontLampMaterial = (material, isReference = false) => {
const isLit = lampStrength > 0.02;
const palette = systems.themePalette || TWIN_THEME_PALETTES.user;
if (material.color) material.color.setHex(isLit ? lampColor : palette.lamp);
if (material.emissive) material.emissive.setHex(isLit ? lampColor : palette.lampEmissive);
if ("emissiveIntensity" in material) {
material.emissiveIntensity = (isReference ? 0.18 : 0.08) + lampStrength * 3.4;
}
if (isReference) {
material.transparent = true;
material.opacity = 0.9 + lampStrength * 0.1;
material.depthWrite = lampStrength > 0.55;
}
material.needsUpdate = true;
};
updateFrontLampMaterial(systems.headlampMaterial);
systems.referenceFrontLampMaterials.forEach((material) => updateFrontLampMaterial(material, true));
systems.referenceFrontLampHighlights.forEach(({
material,
idleOpacity,
activeOpacity,
isHalo,
}) => {
const palette = systems.themePalette || TWIN_THEME_PALETTES.user;
const idleColor = isHalo ? palette.glassGlow : palette.lamp;
material.color.setHex(lampStrength > 0.02 ? lampColor : idleColor);
material.opacity = idleOpacity + lampStrength * (activeOpacity - idleOpacity);
});
const climateStrength = systems.current.climateLevel / 7;
const climateColor = systems.targets.climateTemperature < 24
? 0x55bdec
: systems.targets.climateTemperature > 24
? 0xffa05d
: 0x72d1d0;
systems.colors.climateTarget.setHex(climateColor);
systems.airflow.level = ease(systems.airflow.level, climateStrength, 0.1);
systems.airflow.material.color.lerp(systems.colors.climateTarget, 0.12);
systems.airflow.material.opacity = systems.airflow.level * 0.92;
systems.airflow.material.size = 0.04 + systems.airflow.level * 0.055;
systems.airflow.points.visible = systems.airflow.level > 0.01;
systems.ventMaterials.forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.emissive.lerp(systems.colors.climateTarget, 0.12);
material.emissiveIntensity = 0.3 + systems.airflow.level * 2;
});
const speed = 0.00018 + systems.airflow.level * 0.00042;
systems.airflow.seeds.forEach((seed, index) => {
const progress = (timestamp * speed + seed) % 1;
const lane = index % 8;
const offset = index * 3;
systems.airflow.positions[offset] = -1.22 + lane * 0.13 + Math.sin(progress * 9 + index) * 0.035;
systems.airflow.positions[offset + 1] = 1.35 - progress * 0.5 + Math.sin(progress * Math.PI) * 0.13;
systems.airflow.positions[offset + 2] = 1.38 - progress * 1.68;
});
systems.airflow.attribute.needsUpdate = true;
}
function makeTwinPanel(THREE, parent, shape, depth, position, rotation, material, edgeMaterial) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
steps: 1,
curveSegments: 18,
bevelEnabled: true,
bevelSegments: 4,
bevelSize: 0.045,
bevelThickness: 0.04,
});
geometry.translate(0, 0, -depth / 2);
geometry.computeVertexNormals();
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.rotation.set(...rotation);
mesh.castShadow = true;
mesh.receiveShadow = true;
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 48), edgeMaterial);
mesh.add(edges);
parent.add(mesh);
return mesh;
}
function makeBackrestShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.7 - inset;
shape.moveTo(-0.52 + inset, 0);
shape.quadraticCurveTo(-half, 0.08, -half, 0.34);
shape.lineTo(-half + 0.035, 1.28 - inset);
shape.quadraticCurveTo(-half + 0.06, 1.52 - inset, -0.46 + inset, 1.65 - inset);
shape.quadraticCurveTo(0, 1.82 - inset, 0.46 - inset, 1.65 - inset);
shape.quadraticCurveTo(half - 0.06, 1.52 - inset, half - 0.035, 1.28 - inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.08, 0.52 - inset, 0);
shape.quadraticCurveTo(0, -0.08 + inset, -0.52 + inset, 0);
return shape;
}
function makeCushionShape(THREE, inset = 0) {
const shape = new THREE.Shape();
const half = 0.69 - inset;
shape.moveTo(-0.52 + inset, 0.61 - inset);
shape.quadraticCurveTo(-half, 0.55 - inset, -half, 0.34);
shape.lineTo(-half + 0.025, -0.39 + inset);
shape.quadraticCurveTo(-half + 0.02, -0.61 + inset, -0.48 + inset, -0.68 + inset);
shape.quadraticCurveTo(0, -0.77 + inset, 0.48 - inset, -0.68 + inset);
shape.quadraticCurveTo(half - 0.02, -0.61 + inset, half - 0.025, -0.39 + inset);
shape.lineTo(half, 0.34);
shape.quadraticCurveTo(half, 0.55 - inset, 0.52 - inset, 0.61 - inset);
shape.quadraticCurveTo(0, 0.68 - inset, -0.52 + inset, 0.61 - inset);
return shape;
}
function makeTwinSeam(THREE, parent, points, material) {
const geometry = new THREE.BufferGeometry().setFromPoints(
points.map(([x, y, z]) => new THREE.Vector3(x, y, z)),
);
const seam = new THREE.Line(geometry, material);
parent.add(seam);
return seam;
}
function createTwinVentField(THREE, parent, kind) {
const count = 12;
const positions = new Float32Array(count * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0x39bdf2,
size: 0.055,
sizeAttenuation: true,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.renderOrder = 18;
parent.add(points);
return {
kind,
points,
positions,
attribute: geometry.getAttribute("position"),
material,
};
}
function createTwinHeatPulse(THREE, parent, kind, phase) {
const material = new THREE.MeshBasicMaterial({
color: 0xff633f,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
});
const mesh = new THREE.Mesh(
new THREE.RingGeometry(0.17, 0.205, 32),
material,
);
if (kind === "backrest") {
mesh.position.set(0, 0.88, 0.285);
mesh.scale.set(1.8, 3, 1);
} else {
mesh.position.set(0, 0.205, 0.04);
mesh.rotation.x = -Math.PI / 2;
mesh.scale.set(1.75, 2.35, 1);
}
mesh.visible = false;
mesh.renderOrder = 17;
parent.add(mesh);
return { mesh, material, phase, baseScale: mesh.scale.clone() };
}
function updateTwinThermalVisual(model, timestamp) {
const smoothing = 0.075;
["ventilation", "heating"].forEach((feature) => {
const delta = model.thermalTargets[feature] - model.thermalLevels[feature];
model.thermalLevels[feature] += Math.abs(delta) < 0.002 ? delta : delta * smoothing;
});
const ventilation = model.thermalLevels.ventilation;
const heating = model.thermalLevels.heating;
const selectionFactor = model.isSelected ? 1 : 0.58;
const targetColor = model.thermalColors.base.clone();
targetColor.lerp(model.thermalColors.ventilation, ventilation * 0.5);
targetColor.lerp(model.thermalColors.heating, heating * 0.6);
model.insertMaterial.color.lerp(targetColor, 0.1);
const targetEmissive = model.thermalColors.ventilation.clone().multiplyScalar(
ventilation * 0.14,
);
targetEmissive.lerp(
model.thermalColors.heating,
Math.min(1, heating * 0.34),
);
model.insertMaterial.emissive.lerp(targetEmissive, 0.1);
model.insertMaterial.emissiveIntensity = 0.12 + Math.max(ventilation, heating) * 0.38;
const elapsed = timestamp * (0.0003 + ventilation * 0.00018);
model.ventFields.forEach((field, fieldIndex) => {
field.points.visible = ventilation > 0.015;
field.material.opacity = ventilation * selectionFactor * 0.92;
field.material.size = 0.045 + ventilation * 0.045;
for (let index = 0; index < field.positions.length / 3; index += 1) {
const progress = (elapsed + index / 12 + fieldIndex * 0.17) % 1;
const column = (index % 3) - 1;
const row = Math.floor(index / 3);
const offset = index * 3;
if (field.kind === "backrest") {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.27 + progress * 1.28;
field.positions[offset + 2] = 0.29 + Math.sin(progress * Math.PI) * 0.22;
} else {
field.positions[offset] = column * 0.25 + Math.sin(progress * Math.PI * 2 + row) * 0.025;
field.positions[offset + 1] = 0.205 + progress * 0.36;
field.positions[offset + 2] = -0.36 + row * 0.24 + Math.sin(progress * Math.PI) * 0.05;
}
}
field.attribute.needsUpdate = true;
});
model.heatPulses.forEach((pulse) => {
const progress = (timestamp * 0.00042 + pulse.phase) % 1;
const scale = 0.78 + progress * 0.42;
pulse.mesh.visible = heating > 0.015;
pulse.mesh.scale.copy(pulse.baseScale).multiplyScalar(scale);
pulse.material.opacity = heating * selectionFactor * (1 - progress) * 0.38;
});
}
function createTwinSeat(THREE, x, accentColor) {
const root = new THREE.Group();
root.position.set(x, 0.14, 0.05);
const shell = new THREE.MeshPhysicalMaterial({
color: 0x344950,
roughness: 0.5,
metalness: 0.01,
clearcoat: 0.16,
clearcoatRoughness: 0.72,
});
const insert = new THREE.MeshPhysicalMaterial({
color: 0x9fb1b2,
roughness: 0.68,
metalness: 0,
clearcoat: 0.08,
});
const thermalInsert = insert.clone();
const bolster = new THREE.MeshStandardMaterial({ color: 0x52686f, roughness: 0.72 });
const structure = new THREE.MeshStandardMaterial({ color: 0x26343a, roughness: 0.64, metalness: 0.32 });
const rail = new THREE.MeshStandardMaterial({ color: 0x65757a, roughness: 0.45, metalness: 0.7 });
const edge = new THREE.LineBasicMaterial({ color: 0x182529, transparent: true, opacity: 0.34 });
const seam = new THREE.LineBasicMaterial({ color: accentColor, transparent: true, opacity: 0.88 });
const trackedMaterials = [shell, insert, thermalInsert, bolster];
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [-0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [0.11, 0.08, 2.65], [0.48, 0.12, 0], rail, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, -0.92], structure, edge);
makeTwinBox(THREE, root, [1.15, 0.07, 0.13], [0, 0.15, 0.92], structure, edge);
const slide = new THREE.Group();
root.add(slide);
const base = new THREE.Mesh(new THREE.CapsuleGeometry(0.47, 0.5, 6, 18), structure);
base.rotation.x = Math.PI / 2;
base.scale.set(1.42, 1, 0.55);
base.position.set(0, 0.34, -0.02);
base.castShadow = true;
base.receiveShadow = true;
slide.add(base);
const cushionPivot = new THREE.Group();
cushionPivot.position.set(0, 0.57, 0.06);
slide.add(cushionPivot);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE),
0.22,
[0, 0, 0.08],
[-Math.PI / 2, 0, 0],
shell,
edge,
);
makeTwinPanel(
THREE,
cushionPivot,
makeCushionShape(THREE, 0.16),
0.055,
[0, 0.145, 0.03],
[-Math.PI / 2, 0, 0],
thermalInsert,
edge,
);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.105, 0.86, 5, 12), bolster);
wing.rotation.x = Math.PI / 2;
wing.position.set(side * 0.61, 0.17, 0.08);
wing.castShadow = true;
cushionPivot.add(wing);
}
makeTwinSeam(THREE, cushionPivot, [[-0.28, 0.181, 0.5], [-0.22, 0.181, 0.08], [-0.28, 0.181, -0.42]], seam);
makeTwinSeam(THREE, cushionPivot, [[0.28, 0.181, 0.5], [0.22, 0.181, 0.08], [0.28, 0.181, -0.42]], seam);
const cushionVentField = createTwinVentField(THREE, cushionPivot, "cushion");
const backrestPivot = new THREE.Group();
backrestPivot.position.set(0, 0.62, -0.5);
slide.add(backrestPivot);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE), 0.3, [0, 0, 0], [0, 0, 0], shell, edge);
makeTwinPanel(THREE, backrestPivot, makeBackrestShape(THREE, 0.16), 0.07, [0, 0.04, 0.19], [0, 0, 0], thermalInsert, edge);
for (const side of [-1, 1]) {
const wing = new THREE.Mesh(new THREE.CapsuleGeometry(0.115, 1.2, 6, 14), bolster);
wing.position.set(side * 0.61, 0.78, 0.18);
wing.scale.set(1, 1, 0.72);
wing.castShadow = true;
backrestPivot.add(wing);
}
makeTwinSeam(THREE, backrestPivot, [[-0.28, 0.2, 0.232], [-0.22, 0.83, 0.232], [-0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[0.28, 0.2, 0.232], [0.22, 0.83, 0.232], [0.32, 1.46, 0.232]], seam);
makeTwinSeam(THREE, backrestPivot, [[-0.32, 0.86, 0.234], [0, 0.78, 0.234], [0.32, 0.86, 0.234]], seam);
const backrestVentField = createTwinVentField(THREE, backrestPivot, "backrest");
const heatPulses = [
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0),
createTwinHeatPulse(THREE, cushionPivot, "cushion", 0.5),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.18),
createTwinHeatPulse(THREE, backrestPivot, "backrest", 0.68),
];
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [-0.24, 1.74, 0], rail, edge);
makeTwinBox(THREE, backrestPivot, [0.045, 0.42, 0.045], [0.24, 1.74, 0], rail, edge);
const headrest = new THREE.Mesh(new THREE.CapsuleGeometry(0.31, 0.3, 8, 18), shell);
headrest.scale.set(1.35, 1, 0.5);
headrest.position.set(0, 1.98, 0.01);
headrest.castShadow = true;
backrestPivot.add(headrest);
const headrestInsert = new THREE.Mesh(new THREE.CapsuleGeometry(0.245, 0.23, 8, 18), insert);
headrestInsert.scale.set(1.32, 1, 0.28);
headrestInsert.position.set(0, 1.98, 0.18);
headrestInsert.castShadow = true;
backrestPivot.add(headrestInsert);
const legrestPivot = new THREE.Group();
legrestPivot.position.set(0, 0.64, 0.7);
slide.add(legrestPivot);
const legrestExtension = new THREE.Group();
legrestPivot.add(legrestExtension);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.08),
0.18,
[0, 0, 0.36],
[-Math.PI / 2, 0, 0],
shell,
edge,
).scale.set(0.9, 0.72, 0.54);
makeTwinPanel(
THREE,
legrestExtension,
makeCushionShape(THREE, 0.2),
0.045,
[0, 0.12, 0.4],
[-Math.PI / 2, 0, 0],
insert,
edge,
).scale.set(0.92, 0.74, 0.56);
const outerSide = Math.sign(x);
const anchors = {};
const addAnchor = (name, parent, position) => {
const anchor = new THREE.Object3D();
anchor.position.set(...position);
parent.add(anchor);
anchors[name] = anchor;
};
addAnchor("longitudinal", slide, [outerSide * 0.84, -0.26, -0.9]);
addAnchor("cushion_tilt", cushionPivot, [outerSide * 0.68, 0.2, 0.28]);
addAnchor("height", slide, [outerSide * 0.84, 0.25, -0.12]);
addAnchor("backrest_recline", backrestPivot, [outerSide * 0.7, 1.28, 0.2]);
addAnchor("legrest_height", legrestExtension, [outerSide * 0.7, 0.08, 0.58]);
addAnchor("legrest_extension", legrestExtension, [outerSide * 0.7, 0.1, 0.82]);
addAnchor("backrest_included_angle", slide, [outerSide * 0.83, 0.62, -0.5]);
addAnchor("legrest_included_angle", slide, [-outerSide * 0.7, 0.51, 0.7]);
const angleGuides = {
backrest_recline: createTwinAngleGuide(THREE, slide, accentColor),
legrest_height: createTwinAngleGuide(THREE, slide, accentColor),
};
const defaultAngles = {
backrest_recline: twinAngleForPosition(
"backrest_recline",
TWIN_DEFAULT_POSITIONS.backrest_recline,
),
legrest_height: twinAngleForPosition(
"legrest_height",
TWIN_DEFAULT_POSITIONS.legrest_height,
),
};
return {
root,
slide,
cushionPivot,
backrestPivot,
legrestPivot,
legrestExtension,
anchors,
angleGuides,
outerSide,
trackedMaterials,
materials: {
shell,
insert,
thermalInsert,
bolster,
structure,
rail,
edge,
seam,
},
insertMaterial: thermalInsert,
isSelected: false,
thermalTargets: { ventilation: 0, heating: 0 },
thermalLevels: { ventilation: 0, heating: 0 },
thermalColors: {
base: new THREE.Color(0x9fb1b2),
ventilation: new THREE.Color(0x4b93b7),
heating: new THREE.Color(0xd05f43),
},
ventFields: [cushionVentField, backrestVentField],
heatPulses,
positions: { ...TWIN_DEFAULT_POSITIONS },
targets: { ...TWIN_DEFAULT_POSITIONS },
angles: { ...defaultAngles },
angleTargets: { ...defaultAngles },
};
}
function configureFixedTwinSeat(THREE, model, position, role) {
const cushionAngle = 6.5;
const backrestIncludedAngle = 104;
const legrestIncludedAngle = 95;
model.root.position.set(...position);
model.root.scale.setScalar(0.84);
model.root.userData.twinRole = role;
model.slide.position.z = 0;
model.cushionPivot.rotation.x = THREE.MathUtils.degToRad(-cushionAngle);
model.backrestPivot.rotation.x = (
model.cushionPivot.rotation.x
- THREE.MathUtils.degToRad(backrestIncludedAngle - 90)
);
model.legrestPivot.rotation.x = THREE.MathUtils.degToRad(
180 - cushionAngle - legrestIncludedAngle,
);
model.legrestExtension.position.z = 0;
Object.values(model.angleGuides).forEach((guide) => {
guide.group.visible = false;
});
model.ventFields.forEach((field) => {
field.points.visible = false;
});
model.heatPulses.forEach((pulse) => {
pulse.mesh.visible = false;
});
return model;
}
function setTwinSelection(selected) {
const twin = state.seatTwin;
if (!twin) return;
const focusedSeat = selected === "driver" ? "driver" : "passenger";
twin.selected = focusedSeat;
$("#seatTwin").dataset.selectedSeat = focusedSeat;
$("#seatTwinCanvas").dataset.activeSeat = focusedSeat === "driver" ? "front-left" : "front-right";
$$(".twin-seat-button").forEach((button) => {
const active = button.dataset.twinSeat === focusedSeat;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
Object.entries(twin.models).forEach(([seat, model]) => {
const isSelected = seat === focusedSeat;
model.isSelected = isSelected;
Object.entries(model.angleGuides).forEach(([axis, guide]) => {
guide.group.visible = (
isSelected
&& twin.explicitAngleTargets[focusedSeat]?.[axis] != null
);
});
model.trackedMaterials.forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.depthTest = true;
material.needsUpdate = true;
});
});
twin.view.targetX = focusedSeat === "driver" ? 0.34 : -0.34;
twin.view.yaw = focusedSeat === "driver" ? -0.48 : -0.72;
renderTwinReadout();
}
function renderTwinCalloutValues(device) {
const value = (axis, fallback) => Number(device?.position?.[axis]?.position_pct ?? fallback);
const angle = (axis, fallbackPosition) => Number(
device?.position?.[axis]?.angle_deg
?? twinAngleForPosition(axis, value(axis, fallbackPosition)),
);
const longitudinal = value("longitudinal", 50);
const cushion = value("cushion_tilt", 50);
const height = value("height", 50);
const backrest = value("backrest_recline", 20);
const legrest = value("legrest_height", 0);
const extension = value("legrest_extension", 0);
const postureAngles = twinPostureAngles(device);
const explicitAngles = state.seatTwin?.explicitAngleTargets[
state.seatTwin.selected
] || {};
const backrestEstimated = isEstimatedMotion(device, "backrest_recline");
const legrestEstimated = isEstimatedMotion(device, "legrest_height");
const formatAngle = (number) => (
Number.isInteger(number) ? String(number) : number.toFixed(1)
);
const values = {
longitudinal: [`${longitudinal}%`, longitudinal < 45 ? "前移区" : longitudinal > 55 ? "后移区" : "中位区"],
cushion_tilt: [`${formatAngle(postureAngles.cushion_tilt)}°`, `${cushion}%`],
height: [`${height}%`, "CH1 高度电机"],
backrest_recline: [
`${backrestEstimated ? "≈" : ""}${formatAngle(postureAngles.backrest_recline)}°`,
`${backrestEstimated ? "估算 " : ""}${backrest}% · 相对归零位`,
],
legrest_height: [
`${legrestEstimated ? "≈" : ""}${formatAngle(postureAngles.legrest_height)}°`,
`${legrestEstimated ? "估算 " : ""}${legrest}% · 相对垂直零位`,
],
legrest_extension: [`${extension}%`, "伸缩行程"],
backrest_included_angle: [
`${backrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.backrest_recline ?? angle("backrest_recline", 20))}°`,
backrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
legrest_included_angle: [
`${legrestEstimated ? "≈" : ""}${formatAngle(explicitAngles.legrest_height ?? angle("legrest_height", 0))}°`,
legrestEstimated ? "Hall 已确认 · 绝对位置未标定" : "本轮指定夹角",
],
};
Object.entries(values).forEach(([axis, [primary, secondary]]) => {
const callout = $(`[data-callout="${axis}"]`);
$("b", callout).textContent = primary;
$("small", callout).textContent = secondary;
});
}
function twinDeviceForSeat(seat, actuatorState = state.seatTwin?.actuatorState || {}) {
const device = actuatorState?.[seat] || {};
if (seat !== "driver") return device;
const position = device.position || {};
return {
...device,
position: {
...position,
longitudinal: position.longitudinal || position.horizontal,
cushion_tilt: position.cushion_tilt || position.basin,
backrest_recline: position.backrest_recline || position.backrest,
},
};
}
function twinAxesForSeat(seat) {
return seat === "driver"
? new Set(["longitudinal", "height", "cushion_tilt", "backrest_recline"])
: new Set(["longitudinal", "cushion_tilt", "backrest_recline", "legrest_height", "legrest_extension"]);
}
function renderTwinReadout() {
const twin = state.seatTwin;
if (!twin) return;
const seat = twin.selected === "driver" ? "driver" : "passenger";
const device = twinDeviceForSeat(seat, twin.actuatorState);
const supportedAxes = twinAxesForSeat(seat);
const axisLabels = {
longitudinal: "水平位置", height: "座椅高度", cushion_tilt: "坐垫俯仰",
backrest_recline: "靠背夹角", legrest_height: "腿托夹角", legrest_extension: "腿托伸缩",
};
$("#twinSeatName").textContent = seat === "driver" ? "主驾座椅" : "副驾座椅";
$("#twinSeatZone").textContent = seat === "driver" ? "CH1 · 左前排" : "CH0 · 右前排";
const postureAngles = twinPostureAngles(device);
Object.entries(TWIN_DEFAULT_POSITIONS).forEach(([axis, fallback]) => {
const motion = device?.position?.[axis];
const position = Number(motion?.position_pct ?? fallback);
const postureAngle = postureAngles[axis];
const estimatedPrefix = isEstimatedMotion(device, axis) ? "≈" : "";
const row = $(`[data-twin-axis="${axis}"]`);
if (!row) return;
row.hidden = !supportedAxes.has(axis);
if (axisLabels[axis]) $("span", row).textContent = axisLabels[axis];
$("b", row).textContent = postureAngle == null
? `${estimatedPrefix}${position}%`
: `${estimatedPrefix}${position}% · ${Number(postureAngle).toFixed(Number.isInteger(Number(postureAngle)) ? 0 : 1)}°`;
row.style.setProperty("--value", `${Math.max(0, Math.min(100, position))}%`);
row.classList.toggle("fault", Boolean(device?.position?.[axis]?.fault));
});
$$('[data-callout]').forEach((callout) => {
const axis = callout.dataset.callout;
if (axis?.endsWith("included_angle")) return;
callout.hidden = !supportedAxes.has(axis);
});
["ventilation", "heating"].forEach((feature) => {
const value = device?.[feature] || { state: "off", level: 0 };
const item = $(`[data-twin-function="${feature}"]`);
const enabled = value.state === "on";
const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0;
item.classList.toggle("on", enabled);
item.dataset.level = String(level);
$("b", item).textContent = enabled ? `${level}档` : "关闭";
});
renderTwinCalloutValues(device);
}
function updateTwinExplicitAngles(result) {
const twin = state.seatTwin;
if (!twin) return;
const targets = { passenger: {}, driver: {} };
const requestedSeats = new Set();
const capabilityAxes = {
"seat.set_backrest_recline": "backrest_recline",
"seat.set_legrest_height": "legrest_height",
};
if (["succeeded", "partially_succeeded"].includes(result.status)) {
(result.model_requests || []).forEach((request) => {
const axis = capabilityAxes[request.capability];
const target = request.target_scope;
if (targets[target]) requestedSeats.add(target);
if (!axis || !targets[target]) return;
const parameter = (request.parameters || []).find(
(item) => item.name === "angle_deg" && Number.isFinite(Number(item.value)),
);
if (parameter) targets[target][axis] = Number(parameter.value);
});
}
twin.explicitAngleTargets = targets;
setTwinSelection(requestedSeats.has("driver") ? "driver" : twin.selected);
}
function updateSeatTwin(actuatorState) {
const twin = state.seatTwin;
if (!twin || !actuatorState) return;
twin.actuatorState = actuatorState;
Object.entries(twin.models).forEach(([seat, model]) => {
const device = twinDeviceForSeat(seat, actuatorState);
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
const motion = device?.position?.[axis];
const value = motion?.position_pct;
if (Number.isFinite(Number(value))) model.targets[axis] = Number(value);
if (TWIN_ANGLE_RANGES[axis]) {
const angle = motion?.angle_deg ?? twinAngleForPosition(axis, value);
if (Number.isFinite(Number(angle))) model.angleTargets[axis] = Number(angle);
}
});
["ventilation", "heating"].forEach((feature) => {
const value = device?.[feature];
const enabled = value?.state === "on";
const level = enabled ? Math.max(1, Math.min(3, Number(value.level) || 1)) : 0;
model.thermalTargets[feature] = level / 3;
});
});
renderTwinReadout();
}
const REFERENCE_VEHICLE_ROOT = "/assets/models/byd-tang/";
const REFERENCE_VEHICLE_URL = `${REFERENCE_VEHICLE_ROOT}byd-tang-2024.fbx`;
function referenceMaterialDescriptor(material) {
const textureNames = [
"map",
"aoMap",
"alphaMap",
"normalMap",
"roughnessMap",
"metalnessMap",
"emissiveMap",
].map((key) => {
const image = material?.[key]?.image;
return image?.currentSrc || image?.src || image?.name || "";
});
return [material?.name || "", ...textureNames].join(" ").toLowerCase();
}
function classifyReferenceMaterial(material) {
const descriptor = referenceMaterialDescriptor(material);
if (/shadow/.test(descriptor)) return "shadow";
if (/tyre|tire|wheel|alloy|caliper/.test(descriptor)) return "wheel";
if (/interior|seat|dashboard|cockpit|steer/.test(descriptor)) return "interior";
if (/(^|[_\W])(hl|tl|fl)([_\W]|$)|headlight|taillight|lamp|light|fl_emiss|rear_emiss/.test(descriptor)) return "light";
if (/glass|window|windshield/.test(descriptor)) return "glass";
if (/body|paint|carpaint/.test(descriptor)) return "body";
return "detail";
}
const TWIN_OUTLINE_STYLE = {
body: {
color: 0x315d69,
lineOpacity: 0.48,
occludedColor: 0xa7b8bb,
occludedLineOpacity: 0.1,
fillOpacity: 0.11,
thresholdAngle: 38,
renderOrder: 30,
},
glass: {
color: 0x527c85,
lineOpacity: 0.38,
occludedColor: 0xa5b8bb,
occludedLineOpacity: 0.06,
fillOpacity: 0.045,
thresholdAngle: 38,
renderOrder: 31,
},
detail: {
color: 0x536f76,
lineOpacity: 0.16,
occludedColor: 0xa8b7b9,
occludedLineOpacity: 0.035,
fillOpacity: 0.028,
thresholdAngle: 46,
renderOrder: 32,
},
wheel: {
color: 0x203d45,
lineOpacity: 0.52,
occludedColor: 0x9eafb2,
occludedLineOpacity: 0.14,
fillOpacity: 0.18,
thresholdAngle: 50,
renderOrder: 33,
},
interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, renderOrder: 32 },
};
function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) {
if (
!mesh.geometry?.attributes?.position
|| mesh.userData.twinOutline
|| style.lineOpacity <= 0
) return null;
const geometry = new THREE.EdgesGeometry(mesh.geometry, thresholdAngle);
if (!geometry.getAttribute("position")?.count) {
geometry.dispose();
return null;
}
const visibleMaterial = new THREE.LineBasicMaterial({
color: style.color,
transparent: true,
opacity: style.lineOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.LessEqualDepth,
toneMapped: false,
});
visibleMaterial.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
"#include ",
"#include \ngl_Position.z -= 0.0006 * gl_Position.w;",
);
};
visibleMaterial.customProgramCacheKey = () => "twin-visible-line-depth-bias-v1";
const visibleOutline = new THREE.LineSegments(geometry, visibleMaterial);
visibleOutline.name = `${mesh.name || "mesh"}_outline_visible`;
visibleOutline.renderOrder = style.renderOrder + 1;
visibleOutline.frustumCulled = mesh.frustumCulled;
mesh.add(visibleOutline);
mesh.userData.twinOutline = visibleOutline;
const occludedOpacity = style.occludedLineOpacity ?? 0;
if (occludedOpacity > 0) {
const occludedMaterial = new THREE.LineBasicMaterial({
color: style.occludedColor ?? style.color,
transparent: true,
opacity: occludedOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.GreaterDepth,
toneMapped: false,
});
const occludedOutline = new THREE.LineSegments(geometry, occludedMaterial);
occludedOutline.name = `${mesh.name || "mesh"}_outline_occluded`;
occludedOutline.renderOrder = style.renderOrder;
occludedOutline.frustumCulled = mesh.frustumCulled;
mesh.add(occludedOutline);
mesh.userData.twinOccludedOutline = occludedOutline;
}
return visibleOutline;
}
function applyTwinOutlineStyle(THREE, root, options = {}) {
const style = {
color: options.color ?? 0x52727a,
lineOpacity: options.lineOpacity ?? 0.58,
fillOpacity: options.fillOpacity ?? 0.014,
renderOrder: options.renderOrder ?? 40,
};
const meshes = [];
root.traverse((object) => {
if (object.isMesh && object.visible) meshes.push(object);
});
meshes.forEach((mesh) => {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.filter(Boolean).forEach((material) => {
material.transparent = true;
material.opacity = Math.min(Number(material.opacity ?? 1), style.fillOpacity);
material.depthWrite = false;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
});
mesh.castShadow = false;
mesh.receiveShadow = false;
mesh.renderOrder = style.renderOrder - 1;
addTwinMeshOutline(THREE, mesh, style, options.thresholdAngle ?? 28);
});
}
function configureTwinSolidEntity(THREE, roots, options = {}) {
const items = Array.isArray(roots) ? roots : [roots];
items.filter(Boolean).forEach((root) => {
root.traverse((object) => {
if (!object.isMesh || !object.visible) return;
if (object.userData.twinOutline) object.userData.twinOutline.visible = false;
if (object.userData.twinOccludedOutline) {
object.userData.twinOccludedOutline.visible = false;
}
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
material.transparent = Boolean(options.transparent);
material.opacity = options.opacity ?? 1;
material.depthWrite = options.depthWrite ?? !options.transparent;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
});
object.castShadow = true;
object.receiveShadow = true;
object.renderOrder = options.renderOrder ?? 46;
});
});
}
function configureActivePassengerSeatStyle(model) {
const {
shell,
insert,
thermalInsert,
bolster,
structure,
rail,
edge,
seam,
} = model.materials;
shell.color.setHex(0xd85b27);
shell.roughness = 0.3;
shell.clearcoat = 0.58;
shell.emissive.setHex(0x64210c);
shell.emissiveIntensity = 0.24;
insert.color.setHex(0xffe0a3);
insert.emissive.setHex(0x6d4315);
insert.emissiveIntensity = 0.2;
thermalInsert.color.setHex(0xffc866);
thermalInsert.emissive.setHex(0x7a3d0d);
thermalInsert.emissiveIntensity = 0.24;
bolster.color.setHex(0xb93426);
bolster.emissive.setHex(0x53120d);
bolster.emissiveIntensity = 0.2;
structure.color.setHex(0x39231d);
structure.emissive.setHex(0x1b0d09);
structure.emissiveIntensity = 0.14;
rail.color.setHex(0xa8bdc5);
rail.metalness = 0.82;
[shell, insert, thermalInsert, bolster, structure, rail].forEach((material) => {
material.transparent = false;
material.opacity = 1;
material.depthWrite = true;
material.depthTest = true;
material.alphaTest = 0;
material.colorWrite = true;
material.toneMapped = false;
if ("transmission" in material) material.transmission = 0;
material.needsUpdate = true;
});
edge.color.setHex(0xffd592);
edge.opacity = 1;
edge.depthTest = true;
edge.depthWrite = false;
seam.color.setHex(0xfff1cd);
seam.opacity = 1;
seam.depthTest = true;
seam.depthWrite = false;
model.thermalColors.base.setHex(0xffc866);
model.root.traverse((object) => {
if (!object.isLine && !object.isLineSegments) return;
object.renderOrder = 53;
});
}
function configureActiveSeatDepthLayers(THREE, model) {
const seatMeshes = [];
model.root.traverse((object) => {
if (object.isMesh && object.visible) seatMeshes.push(object);
});
seatMeshes.forEach((object) => {
object.renderOrder = 51;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const ghostMaterials = sourceMaterials.map((source) => new THREE.MeshBasicMaterial({
color: source?.color?.getHex?.() ?? 0xd98b55,
transparent: true,
opacity: 0.18,
depthWrite: false,
depthTest: true,
depthFunc: THREE.GreaterDepth,
side: THREE.DoubleSide,
toneMapped: false,
}));
const ghost = new THREE.Mesh(
object.geometry,
Array.isArray(object.material) ? ghostMaterials : ghostMaterials[0],
);
ghost.name = `${object.name || "seat"}_occluded_ghost`;
ghost.userData = { twinRole: "active_seat_occluded_ghost" };
ghost.renderOrder = 52;
ghost.castShadow = false;
ghost.receiveShadow = false;
object.add(ghost);
object.userData.twinOccludedGhost = ghost;
addTwinMeshOutline(THREE, object, {
color: 0xffd592,
lineOpacity: 0.54,
occludedColor: 0xd98b55,
occludedLineOpacity: 0.14,
fillOpacity: 0,
renderOrder: 52,
}, 28);
});
}
function addTwinGlassDepthMask(THREE, glassMesh) {
if (!glassMesh?.geometry || glassMesh.userData.twinDepthMask) return null;
const material = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
const mask = new THREE.Mesh(glassMesh.geometry, material);
mask.name = `${glassMesh.name || "glass"}_depth_mask`;
mask.userData = { twinRole: "glass_depth_mask" };
mask.renderOrder = 48;
mask.castShadow = false;
mask.receiveShadow = false;
glassMesh.add(mask);
glassMesh.userData.twinDepthMask = mask;
return mask;
}
function visibleTwinMeshBounds(THREE, root) {
const bounds = new THREE.Box3();
const meshBounds = new THREE.Box3();
root.updateWorldMatrix(true, true);
root.traverse((object) => {
if (!object.isMesh || !object.visible || !object.geometry?.attributes?.position) return;
if (!object.geometry.boundingBox) object.geometry.computeBoundingBox();
meshBounds.copy(object.geometry.boundingBox).applyMatrix4(object.matrixWorld);
bounds.union(meshBounds);
});
return bounds;
}
function configureReferenceVehicleModel(THREE, vehicle) {
const materialCache = new Map();
const report = { meshes: 0, materials: new Map(), nodes: [] };
vehicle.traverse((object) => {
if (object.name) report.nodes.push(object.name);
if (!object.isMesh) return;
report.meshes += 1;
object.castShadow = false;
object.receiveShadow = false;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const configuredMaterials = sourceMaterials.map((sourceMaterial) => {
if (!sourceMaterial) return sourceMaterial;
if (materialCache.has(sourceMaterial)) return materialCache.get(sourceMaterial);
const material = sourceMaterial.clone();
const descriptor = referenceMaterialDescriptor(material);
const category = classifyReferenceMaterial(material);
const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail;
const key = `${category}:${material.name || "unnamed"}`;
report.materials.set(key, (report.materials.get(key) || 0) + 1);
[
"map",
"aoMap",
"bumpMap",
"normalMap",
"roughnessMap",
"metalnessMap",
"emissiveMap",
].forEach((textureKey) => {
if (textureKey in material) material[textureKey] = null;
});
if (material.emissive) {
material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
}
if (category === "glass") {
if (material.color) material.color.setHex(0x55727b);
material.transparent = true;
material.opacity = 0.3;
material.depthWrite = false;
material.side = THREE.DoubleSide;
object.castShadow = false;
object.renderOrder = 12;
} else if (category === "body") {
if (material.color) material.color.setHex(0x829398);
if ("shininess" in material) material.shininess = 72;
} else if (category === "light") {
material.map = null;
if (material.color) material.color.setHex(0x697b80);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
} else if (category === "interior" && material.color) {
material.color.setHex(0x6c7778);
} else if (/lines|\badd\b/.test(descriptor) && material.color) {
material.map = null;
material.color.setHex(0x4c5a5d);
} else if (/map[_ -]?[abc]/.test(descriptor) && material.color) {
if (/chrome|metal/.test(descriptor)) material.color.setHex(0xaeb9bb);
else if (/plastic/.test(descriptor)) material.color.setHex(0x303a3e);
else material.color.setHex(0x7f8e91);
} else if (/plastic|under|black/.test(descriptor) && material.color) {
material.color.setHex(0x303a3e);
} else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) {
material.color.setHex(0xaeb9bb);
}
material.transparent = true;
material.opacity = outlineStyle.fillOpacity;
material.depthWrite = ["body", "detail", "wheel"].includes(category);
material.depthTest = true;
material.side = category === "glass" ? THREE.DoubleSide : THREE.FrontSide;
material.wireframe = false;
if ("flatShading" in material) material.flatShading = false;
if ("roughness" in material) material.roughness = category === "body" ? 0.72 : 0.82;
if ("metalness" in material) material.metalness = category === "wheel" ? 0.48 : 0.08;
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.needsUpdate = true;
materialCache.set(sourceMaterial, material);
return material;
});
object.material = Array.isArray(object.material) ? configuredMaterials : configuredMaterials[0];
const categories = configuredMaterials.filter(Boolean).map(classifyReferenceMaterial);
if (categories.length && categories.every((category) => category === "shadow")) object.visible = false;
if (!object.visible) return;
const categoryPriority = ["body", "glass", "wheel", "light", "detail", "interior"];
const outlineCategory = categoryPriority.find((category) => categories.includes(category)) || "detail";
const outlineStyle = TWIN_OUTLINE_STYLE[outlineCategory];
object.renderOrder = outlineStyle.renderOrder - 1;
const isControllableWindowPane = String(object.userData.twinRole || "")
.startsWith("reference_window_");
if (!isControllableWindowPane) {
addTwinMeshOutline(
THREE,
object,
outlineStyle,
outlineStyle.thresholdAngle ?? 60,
);
}
});
return report;
}
function normalizeReferenceVehicle(THREE, vehicle) {
vehicle.updateMatrixWorld(true);
let bounds = new THREE.Box3().setFromObject(vehicle);
let size = bounds.getSize(new THREE.Vector3());
if (size.x > size.z) {
vehicle.rotateY(Math.PI / 2);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
size = bounds.getSize(new THREE.Vector3());
}
const scale = 7.45 / Math.max(size.x, size.z);
vehicle.scale.multiplyScalar(scale);
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
const center = bounds.getCenter(new THREE.Vector3());
vehicle.position.x -= center.x;
vehicle.position.z -= center.z;
vehicle.position.y += -0.145 - bounds.min.y;
vehicle.updateMatrixWorld(true);
bounds = new THREE.Box3().setFromObject(vehicle);
return { bounds, size: bounds.getSize(new THREE.Vector3()), scale };
}
function measureReferenceSeatCushion(THREE, vehicle, minX, maxX) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return null;
interior.updateWorldMatrix(true, false);
const geometry = interior.geometry.index
? interior.geometry.toNonIndexed()
: interior.geometry;
const positions = geometry.getAttribute("position");
const centroid = new THREE.Vector3();
const vertex = new THREE.Vector3();
const samples = [];
for (let offset = 0; offset < positions.count; offset += 3) {
centroid.set(0, 0, 0);
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
vertex.fromBufferAttribute(positions, offset + vertexOffset);
vertex.applyMatrix4(interior.matrixWorld);
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
const insideSeatCushion = (
centroid.x >= minX
&& centroid.x <= maxX
&& centroid.y >= 0.25
&& centroid.y <= 1.15
&& centroid.z >= -0.12
&& centroid.z <= 0.98
);
if (insideSeatCushion) samples.push(centroid.y);
}
if (geometry !== interior.geometry) geometry.dispose();
if (samples.length < 20) return null;
samples.sort((left, right) => left - right);
const quantile = (ratio) => samples[Math.min(
samples.length - 1,
Math.floor((samples.length - 1) * ratio),
)];
return {
triangleCount: samples.length,
medianY: quantile(0.5),
upperQuartileY: quantile(0.75),
surfaceY: quantile(0.9),
};
}
function measureReferenceDriverCushion(THREE, vehicle) {
return measureReferenceSeatCushion(THREE, vehicle, 0.18, 1.18);
}
function measureReferencePassengerCushion(THREE, vehicle) {
return measureReferenceSeatCushion(THREE, vehicle, -1.18, -0.18);
}
function carveReferenceFrontSeats(THREE, vehicle) {
const interior = vehicle.getObjectByName("Interior");
if (!interior?.isMesh || !interior.geometry?.attributes?.position) return 0;
interior.updateWorldMatrix(true, false);
const source = interior.geometry.index
? interior.geometry.toNonIndexed()
: interior.geometry.clone();
const positions = source.getAttribute("position");
const centroid = new THREE.Vector3();
const vertex = new THREE.Vector3();
const keptVertices = [];
let removedTriangles = 0;
for (let offset = 0; offset < positions.count; offset += 3) {
centroid.set(0, 0, 0);
for (let vertexOffset = 0; vertexOffset < 3; vertexOffset += 1) {
vertex.fromBufferAttribute(positions, offset + vertexOffset);
vertex.applyMatrix4(interior.matrixWorld);
centroid.add(vertex);
}
centroid.multiplyScalar(1 / 3);
const insideFrontSeat = (
((centroid.x >= -1.15 && centroid.x <= -0.12)
|| (centroid.x >= 0.12 && centroid.x <= 1.15))
&& centroid.y >= 0.05
&& centroid.y <= 2.05
&& centroid.z >= -0.92
&& centroid.z <= 1.08
);
if (insideFrontSeat) {
removedTriangles += 1;
continue;
}
keptVertices.push(offset, offset + 1, offset + 2);
}
if (removedTriangles < 40) {
source.dispose();
return 0;
}
const carved = new THREE.BufferGeometry();
Object.entries(source.attributes).forEach(([name, attribute]) => {
const values = new attribute.array.constructor(keptVertices.length * attribute.itemSize);
keptVertices.forEach((sourceIndex, targetIndex) => {
for (let component = 0; component < attribute.itemSize; component += 1) {
values[targetIndex * attribute.itemSize + component] = (
attribute.array[sourceIndex * attribute.itemSize + component]
);
}
});
carved.setAttribute(
name,
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
);
});
carved.computeBoundingBox();
carved.computeBoundingSphere();
interior.geometry = carved;
source.dispose();
return removedTriangles;
}
function makeTriangleSubsetGeometry(THREE, source, vertexIndices) {
const subset = new THREE.BufferGeometry();
Object.entries(source.attributes).forEach(([name, attribute]) => {
const values = new attribute.array.constructor(vertexIndices.length * attribute.itemSize);
vertexIndices.forEach((sourceIndex, targetIndex) => {
for (let component = 0; component < attribute.itemSize; component += 1) {
values[targetIndex * attribute.itemSize + component] = (
attribute.array[sourceIndex * attribute.itemSize + component]
);
}
});
subset.setAttribute(
name,
new THREE.BufferAttribute(values, attribute.itemSize, attribute.normalized),
);
});
subset.computeBoundingBox();
subset.computeBoundingSphere();
return subset;
}
function extractReferenceWindowPanes(THREE, vehicle) {
const panes = {
front_left: [],
front_right: [],
rear_left: [],
rear_right: [],
};
vehicle.updateMatrixWorld(true);
const sources = [];
vehicle.traverse((object) => {
if (object.isMesh && /^(window_gls|black_glasses)$/i.test(object.name || "")) {
sources.push(object);
}
});
sources.forEach((object) => {
const originalGeometry = object.geometry;
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
const positions = source.getAttribute("position");
const retainedVertices = [];
const paneVertices = Object.fromEntries(Object.keys(panes).map((key) => [key, []]));
const a = new THREE.Vector3();
const b = new THREE.Vector3();
const c = new THREE.Vector3();
const centroid = new THREE.Vector3();
const normal = new THREE.Vector3();
const edge = new THREE.Vector3();
const rearCandidates = [];
const pointKey = (point) => [point.x, point.y, point.z]
.map((value) => Math.round(value * 10000))
.join(":");
const edgeKey = (start, end) => [start, end].sort().join("|");
for (let index = 0; index < positions.count; index += 3) {
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
normal.copy(b).sub(a).cross(edge.copy(c).sub(a)).normalize();
const isSideWindow = (
Math.abs(normal.x) >= 0.48
&& Math.abs(normal.x) >= Math.abs(normal.z) * 1.2
&& Math.abs(centroid.x) >= 0.58
&& centroid.y >= 1.42
);
if (!isSideWindow) {
retainedVertices.push(index, index + 1, index + 2);
continue;
}
// The reference model uses positive X for the driver/vehicle-left side.
const side = centroid.x < 0 ? "right" : "left";
if (/^window_gls$/i.test(object.name || "")) {
paneVertices[`front_${side}`].push(index, index + 1, index + 2);
continue;
}
const vertexKeys = [pointKey(a), pointKey(b), pointKey(c)];
rearCandidates.push({
indices: [index, index + 1, index + 2],
side,
maxZ: Math.max(a.z, b.z, c.z),
edgeKeys: [
edgeKey(vertexKeys[0], vertexKeys[1]),
edgeKey(vertexKeys[1], vertexKeys[2]),
edgeKey(vertexKeys[2], vertexKeys[0]),
],
});
}
if (rearCandidates.length) {
const parents = rearCandidates.map((_, index) => index);
const find = (index) => {
let root = index;
while (parents[root] !== root) root = parents[root];
while (parents[index] !== index) {
const next = parents[index];
parents[index] = root;
index = next;
}
return root;
};
const unite = (left, right) => {
const leftRoot = find(left);
const rightRoot = find(right);
if (leftRoot !== rightRoot) parents[rightRoot] = leftRoot;
};
const edgeOwners = new Map();
rearCandidates.forEach((candidate, candidateIndex) => {
candidate.edgeKeys.forEach((key) => {
if (edgeOwners.has(key)) unite(candidateIndex, edgeOwners.get(key));
else edgeOwners.set(key, candidateIndex);
});
});
const components = new Map();
rearCandidates.forEach((candidate, candidateIndex) => {
const root = find(candidateIndex);
if (!components.has(root)) {
components.set(root, { side: candidate.side, maxZ: candidate.maxZ, candidates: [] });
}
const component = components.get(root);
component.maxZ = Math.max(component.maxZ, candidate.maxZ);
component.candidates.push(candidate);
});
const leadingMaxZ = { left: -Infinity, right: -Infinity };
components.forEach((component) => {
leadingMaxZ[component.side] = Math.max(leadingMaxZ[component.side], component.maxZ);
});
components.forEach((component) => {
const isRearDoorWindow = component.maxZ >= leadingMaxZ[component.side] - 0.38;
component.candidates.forEach((candidate) => {
if (isRearDoorWindow) paneVertices[`rear_${candidate.side}`].push(...candidate.indices);
else retainedVertices.push(...candidate.indices);
});
});
}
const retainedGeometry = makeTriangleSubsetGeometry(THREE, source, retainedVertices);
object.geometry = retainedGeometry;
Object.entries(paneVertices).forEach(([key, vertexIndices]) => {
if (!vertexIndices.length) return;
const pane = object.clone(false);
pane.name = `${object.name}_${key}`;
pane.geometry = makeTriangleSubsetGeometry(THREE, source, vertexIndices);
pane.userData = { ...object.userData, twinRole: `reference_window_${key}` };
object.parent.add(pane);
panes[key].push({ mesh: pane });
});
originalGeometry.dispose();
if (source !== originalGeometry) source.dispose();
});
vehicle.updateMatrixWorld(true);
Object.values(panes).flat().forEach((pane) => {
const closedPosition = pane.mesh.position.clone();
const openWorldPosition = pane.mesh.getWorldPosition(new THREE.Vector3());
openWorldPosition.y -= 0.86;
const openPosition = pane.mesh.parent.worldToLocal(openWorldPosition);
pane.closedPosition = closedPosition;
pane.openPosition = openPosition;
});
return panes;
}
function referenceFrontLampMaterialIndexes(mesh) {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
return materials.flatMap((material, index) => {
if (!material) return [];
const materialName = String(material.name || "").trim();
return /^fl[_ -]?light$/i.test(materialName) ? [index] : [];
});
}
function collectReferenceFrontLampMeshes(vehicle) {
const meshes = [];
vehicle.traverse((object) => {
if (!object.isMesh) return;
if (referenceFrontLampMaterialIndexes(object).length) meshes.push(object);
});
return meshes;
}
function emphasizeReferenceWindowPanes(THREE, panes) {
Object.values(panes).flat().forEach((pane) => {
const materials = Array.isArray(pane.mesh.material) ? pane.mesh.material : [pane.mesh.material];
const emphasizedMaterials = materials.filter(Boolean).map((source) => {
const material = source.clone();
if (material.color) material.color.setHex(0x258f89);
if (material.emissive) material.emissive.setHex(0x124c49);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.38;
material.transparent = true;
material.opacity = 0.6;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.toneMapped = false;
material.needsUpdate = true;
return material;
});
pane.mesh.material = Array.isArray(pane.mesh.material)
? emphasizedMaterials
: emphasizedMaterials[0];
pane.mesh.renderOrder = 58;
pane.materials = emphasizedMaterials;
pane.outline = addTwinMeshOutline(THREE, pane.mesh, {
color: 0x086861,
lineOpacity: 0.98,
fillOpacity: 0.6,
renderOrder: 59,
}, 22);
pane.depthMask = addTwinGlassDepthMask(THREE, pane.mesh);
});
}
function updateReferenceWindowOutlineLayers(systems, camera) {
if (!systems || !camera) return;
const nearSide = camera.position.x < 0 ? "right" : "left";
Object.entries(systems.referenceWindowPanes).forEach(([key, panes]) => {
const isForegroundWindow = key.endsWith(`_${nearSide}`);
panes.forEach((pane) => {
if (!pane.outline?.material) return;
pane.outline.material.depthTest = !isForegroundWindow;
pane.outline.material.depthWrite = false;
pane.outline.renderOrder = isForegroundWindow ? 66 : 60;
});
});
}
function styleReferencePassiveGlass(THREE, vehicle) {
vehicle.traverse((object) => {
if (!object.isMesh) return;
if (String(object.userData.twinRole || "").startsWith("reference_window_")) return;
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
if (!categories.includes("glass")) return;
const neutralMaterials = sourceMaterials.map((source) => {
if (!source) return source;
if (classifyReferenceMaterial(source) !== "glass") return source;
const material = source.clone();
if (material.color) material.color.setHex(0x809497);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.transparent = true;
material.opacity = 0.3;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.needsUpdate = true;
return material;
});
object.material = Array.isArray(object.material) ? neutralMaterials : neutralMaterials[0];
if (categories.every((category) => category === "glass")) {
object.renderOrder = 37;
if (object.userData.twinOutline?.material) {
object.userData.twinOutline.material.color.setHex(0x5d747a);
object.userData.twinOutline.material.opacity = 0.48;
}
}
});
}
function styleReferencePassiveRearExterior(THREE, vehicle) {
const rearLampMeshes = [];
const rearBumperMeshes = [];
const bounds = new THREE.Box3();
const sourceMeshes = [];
const bumperSourceNames = new Set([
"bodypaint_a",
"blk_part",
"black_matte",
"crom_part",
"uc001",
]);
vehicle.updateMatrixWorld(true);
vehicle.traverse((object) => {
if (object.isMesh && object.geometry?.attributes?.position) sourceMeshes.push(object);
});
sourceMeshes.forEach((object) => {
bounds.setFromObject(object);
const sourceMaterials = Array.isArray(object.material) ? object.material : [object.material];
const categories = sourceMaterials.filter(Boolean).map(classifyReferenceMaterial);
const objectName = String(object.name || "").toLowerCase();
const isRearLight = (
bounds.max.z < -2.65
&& (
categories.includes("light")
|| /(?:brake|brk|red_light)/.test(objectName)
)
);
if (isRearLight) {
const isRedLens = /(?:brake|brk|red_light)/.test(objectName)
|| sourceMaterials.some((material) => /rear_emiss/i.test(material?.name || ""));
const styledMaterials = sourceMaterials.map((source) => {
if (!source) return source;
const material = source.clone();
if (material.color) material.color.setHex(isRedLens ? 0x8f666a : 0x657174);
if (material.emissive) material.emissive.setHex(0x000000);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0;
material.transparent = true;
material.opacity = isRedLens ? 0.4 : 0.2;
material.depthWrite = false;
material.depthTest = true;
material.side = THREE.DoubleSide;
material.toneMapped = false;
material.needsUpdate = true;
return material;
});
object.material = Array.isArray(object.material) ? styledMaterials : styledMaterials[0];
object.renderOrder = 39;
let outline = object.userData.twinOutline;
if (!outline) {
outline = addTwinMeshOutline(THREE, object, {
color: isRedLens ? 0x745459 : 0x53666a,
lineOpacity: isRedLens ? 0.44 : 0.4,
fillOpacity: isRedLens ? 0.4 : 0.2,
renderOrder: 40,
}, 34);
} else if (outline.material) {
outline.material.color.setHex(isRedLens ? 0x745459 : 0x53666a);
outline.material.opacity = isRedLens ? 0.44 : 0.4;
}
rearLampMeshes.push(object);
return;
}
if (!bumperSourceNames.has(objectName)) return;
const originalGeometry = object.geometry;
const source = originalGeometry.index ? originalGeometry.toNonIndexed() : originalGeometry;
const positions = source.getAttribute("position");
const selectedVertices = [];
const a = new THREE.Vector3();
const b = new THREE.Vector3();
const c = new THREE.Vector3();
const centroid = new THREE.Vector3();
for (let index = 0; index < positions.count; index += 3) {
a.fromBufferAttribute(positions, index).applyMatrix4(object.matrixWorld);
b.fromBufferAttribute(positions, index + 1).applyMatrix4(object.matrixWorld);
c.fromBufferAttribute(positions, index + 2).applyMatrix4(object.matrixWorld);
centroid.copy(a).add(b).add(c).multiplyScalar(1 / 3);
if (
centroid.z <= -3.28
&& centroid.y >= 0.18
&& centroid.y <= 1.42
) {
selectedVertices.push(index, index + 1, index + 2);
}
}
if (!selectedVertices.length) {
if (source !== originalGeometry) source.dispose();
return;
}
const isDarkTrim = /(?:blk|black|uc)/.test(objectName);
const isChrome = /crom/.test(objectName);
const material = new THREE.MeshBasicMaterial({
color: isDarkTrim ? 0x48575a : (isChrome ? 0x99a6a8 : 0x78898d),
transparent: true,
opacity: isDarkTrim ? 0.2 : 0.16,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
toneMapped: false,
});
const bumper = new THREE.Mesh(
makeTriangleSubsetGeometry(THREE, source, selectedVertices),
material,
);
bumper.name = `${object.name || "rear"}_passive_bumper`;
bumper.userData = { twinRole: "reference_passive_rear_bumper" };
bumper.renderOrder = 38;
bumper.castShadow = false;
bumper.receiveShadow = false;
object.add(bumper);
addTwinMeshOutline(THREE, bumper, {
color: isDarkTrim ? 0x3d5054 : 0x52696e,
lineOpacity: 0.42,
fillOpacity: isDarkTrim ? 0.2 : 0.16,
renderOrder: 39,
}, 34);
rearBumperMeshes.push(bumper);
if (source !== originalGeometry) source.dispose();
});
return { rearLampMeshes, rearBumperMeshes };
}
function emphasizeReferenceFrontLampMeshes(THREE, meshes) {
const materials = new Set();
meshes.forEach((mesh) => {
const sourceMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
const emphasizedMaterials = sourceMaterials.map((source, index) => {
if (!source || !selectedIndexes.has(index)) return source;
const material = source.clone();
if (material.color) material.color.setHex(0x087b74);
if (material.emissive) material.emissive.setHex(0x16aaa1);
if ("emissiveIntensity" in material) material.emissiveIntensity = 0.95;
material.transparent = true;
material.opacity = 0.9;
material.depthWrite = false;
material.depthTest = true;
material.toneMapped = false;
material.needsUpdate = true;
materials.add(material);
return material;
});
mesh.material = Array.isArray(mesh.material) ? emphasizedMaterials : emphasizedMaterials[0];
mesh.renderOrder = 41;
});
return [...materials];
}
function createReferenceFrontLampHighlights(THREE, meshes) {
return meshes.flatMap((mesh, index) => {
const selectedIndexes = new Set(referenceFrontLampMaterialIndexes(mesh));
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
const groups = source.groups.length
? source.groups
: [{ start: 0, count: source.getAttribute("position").count, materialIndex: 0 }];
const selectedVertices = [];
groups.forEach((group) => {
if (!selectedIndexes.has(group.materialIndex || 0)) return;
for (let vertexIndex = group.start; vertexIndex < group.start + group.count; vertexIndex += 1) {
selectedVertices.push(vertexIndex);
}
});
if (!selectedVertices.length) {
if (source !== mesh.geometry) source.dispose();
return [];
}
const selectedGeometry = makeTriangleSubsetGeometry(THREE, source, selectedVertices);
const material = new THREE.MeshBasicMaterial({
color: 0x087b74,
transparent: true,
opacity: 0.92,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
toneMapped: false,
});
const highlight = mesh.clone(false);
highlight.name = `${mesh.name || "front_lamp"}_twin_highlight_${index}`;
highlight.geometry = selectedGeometry;
highlight.material = material;
highlight.userData = { twinRole: "reference_front_lamp_highlight" };
highlight.renderOrder = 46;
highlight.castShadow = false;
highlight.receiveShadow = false;
mesh.parent.add(highlight);
addTwinMeshOutline(THREE, highlight, {
color: 0x003f3b,
lineOpacity: 1,
fillOpacity: 0.92,
renderOrder: 47,
}, 8);
// The source lamp emitter is a very slim strip. Add a low-opacity copy that
// expands only across the lamp's own vertical envelope, so the headlamps
// remain attached to the original lens instead of looking like floating UI.
const haloGeometry = selectedGeometry.clone();
const haloPositions = haloGeometry.getAttribute("position");
if (!haloGeometry.boundingBox) haloGeometry.computeBoundingBox();
const center = haloGeometry.boundingBox.getCenter(new THREE.Vector3());
for (let vertexIndex = 0; vertexIndex < haloPositions.count; vertexIndex += 1) {
const x = haloPositions.getX(vertexIndex);
const y = haloPositions.getY(vertexIndex);
const z = haloPositions.getZ(vertexIndex);
haloPositions.setXYZ(
vertexIndex,
center.x + (x - center.x) * 1.035,
center.y + (y - center.y) * 2.25,
center.z + (z - center.z) * 1.035,
);
}
haloPositions.needsUpdate = true;
haloGeometry.computeBoundingBox();
haloGeometry.computeBoundingSphere();
const haloMaterial = new THREE.MeshBasicMaterial({
color: 0x49e1d6,
transparent: true,
opacity: 0.38,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
toneMapped: false,
});
const halo = mesh.clone(false);
halo.name = `${mesh.name || "front_lamp"}_twin_halo_${index}`;
halo.geometry = haloGeometry;
halo.material = haloMaterial;
halo.userData = { twinRole: "reference_front_lamp_halo" };
halo.renderOrder = 45;
halo.castShadow = false;
halo.receiveShadow = false;
mesh.parent.add(halo);
if (source !== mesh.geometry) source.dispose();
return [
{
mesh: highlight,
material,
idleOpacity: 0.92,
activeOpacity: 1,
isHalo: false,
},
{
mesh: halo,
material: haloMaterial,
idleOpacity: 0.38,
activeOpacity: 0.68,
isHalo: true,
},
];
});
}
function createReferenceFrontLampLensOutlines(THREE, vehicle) {
const lensOutlines = [];
vehicle.traverse((object) => {
if (!object.isMesh || !/^glass001$/i.test(object.name || "")) return;
const lens = object.clone(false);
lens.name = `${object.name}_twin_lens_contour`;
lens.material = new THREE.MeshBasicMaterial({
color: 0x1b8f88,
transparent: true,
opacity: 0.045,
depthWrite: false,
depthTest: true,
side: THREE.DoubleSide,
toneMapped: false,
});
lens.userData = { twinRole: "reference_front_lamp_lens_contour" };
lens.renderOrder = 43;
lens.castShadow = false;
lens.receiveShadow = false;
object.parent.add(lens);
const outline = addTwinMeshOutline(THREE, lens, {
color: 0x096d67,
lineOpacity: 0.96,
fillOpacity: 0.045,
renderOrder: 44,
}, 24);
if (outline) lensOutlines.push({ mesh: lens, outline });
});
return lensOutlines;
}
function loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback) {
return new Promise((resolve, reject) => {
const FBXLoaderClass = window.FBXLoader || THREE.FBXLoader;
if (!FBXLoaderClass) {
reject(new Error("FBXLoader is unavailable"));
return;
}
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 passengerCushion = measureReferencePassengerCushion(THREE, vehicle);
const removedFrontSeatTriangles = carveReferenceFrontSeats(THREE, vehicle);
const referenceWindowPanes = extractReferenceWindowPanes(THREE, vehicle);
const report = configureReferenceVehicleModel(THREE, vehicle);
styleReferencePassiveGlass(THREE, vehicle);
const passiveRearExterior = styleReferencePassiveRearExterior(THREE, vehicle);
const referenceFrontLampMeshes = collectReferenceFrontLampMeshes(vehicle);
emphasizeReferenceWindowPanes(THREE, referenceWindowPanes);
const referenceFrontLampMaterials = emphasizeReferenceFrontLampMeshes(
THREE,
referenceFrontLampMeshes,
);
const referenceFrontLampHighlights = createReferenceFrontLampHighlights(
THREE,
referenceFrontLampMeshes,
);
const referenceFrontLampLensOutlines = createReferenceFrontLampLensOutlines(
THREE,
vehicle,
);
vehicleSystems.referenceWindowPanes = referenceWindowPanes;
vehicleSystems.referenceFrontLampMaterials = referenceFrontLampMaterials;
vehicleSystems.referenceFrontLampMeshes = referenceFrontLampMeshes;
vehicleSystems.referenceFrontLampHighlights = referenceFrontLampHighlights;
vehicleSystems.referenceFrontLampLensOutlines = referenceFrontLampLensOutlines;
applyTwinVehicleTheme(vehicleSystems);
vehicle.name = "REFERENCE_VEHICLE_OUTLINE";
vehicle.userData.twinRole = "reference_vehicle";
vehicle.traverse((object) => object.layers.set(1));
scene.add(vehicle);
vehicleSystems.fallbackExteriorObjects.forEach((object) => {
object.visible = false;
});
vehicleSystems.fallbackInteriorObjects.forEach((object) => {
object.visible = false;
});
console.info("[vehicle-twin] reference vehicle model loaded", JSON.stringify({
meshes: report.meshes,
size: normalized.size.toArray().map((value) => Number(value.toFixed(3))),
removedFrontSeatTriangles,
windowPanes: Object.fromEntries(
Object.entries(referenceWindowPanes).map(([key, value]) => [key, value.length]),
),
frontLampMaterials: referenceFrontLampMaterials.length,
frontLampMeshes: referenceFrontLampMeshes.length,
frontLampHighlights: referenceFrontLampHighlights.length,
frontLampLensOutlines: referenceFrontLampLensOutlines.length,
rearLampMeshes: passiveRearExterior.rearLampMeshes.length,
rearBumperMeshes: passiveRearExterior.rearBumperMeshes.length,
driverCushion,
passengerCushion,
}));
resolve({ vehicle, report, normalized, driverCushion, passengerCushion });
},
(event) => {
if (!fallback || !event.total) return;
const progress = Math.min(99, Math.round(event.loaded / event.total * 100));
const label = fallback.querySelector("span");
if (label) label.textContent = `正在加载整车参考模型 · ${progress}%`;
},
reject,
);
});
}
function initSeatTwin() {
const canvas = $("#seatTwinCanvas");
const stage = $("#seatTwin");
const fallback = $("#twinFallback");
if (!canvas || !stage) return;
if (!window.THREE) {
fallback.classList.add("error");
fallback.innerHTML = '3D 引擎加载失败,精确状态仍可用';
refreshIcons();
return;
}
const THREE = window.THREE;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: "high-performance" });
renderer.autoClear = false;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
else renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xe8eeec);
scene.fog = new THREE.Fog(0xe8eeec, 12, 19);
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2);
hemisphereLight.layers.enable(1);
scene.add(hemisphereLight);
const keyLight = new THREE.DirectionalLight(0xffffff, 3.2);
keyLight.layers.enable(1);
keyLight.position.set(-4, 7, 6);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.left = -6;
keyLight.shadow.camera.right = 6;
keyLight.shadow.camera.top = 6;
keyLight.shadow.camera.bottom = -4;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x9fc7d7, 1.15);
fillLight.layers.enable(1);
fillLight.position.set(5, 3, -5);
scene.add(fillLight);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(12, 9),
new THREE.MeshStandardMaterial({ color: 0xdfe7e4, roughness: 0.86 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.16;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(10, 20, 0xaebdb9, 0xcbd6d2);
grid.position.y = -0.155;
grid.material.transparent = true;
grid.material.opacity = 0.16;
scene.add(grid);
const vehicleSystems = createTwinCabin(THREE, scene);
applyTwinOutlineStyle(THREE, vehicleSystems.root, {
color: 0x4f747c,
lineOpacity: 0.46,
fillOpacity: 0.012,
renderOrder: 40,
thresholdAngle: 30,
});
configureTwinSolidEntity(THREE, vehicleSystems.ventMeshes, {
renderOrder: 52,
});
configureTwinSolidEntity(THREE, vehicleSystems.ambientMeshes, {
renderOrder: 56,
});
configureTwinSolidEntity(THREE, vehicleSystems.roofGlass, {
transparent: true,
opacity: 0.52,
depthWrite: false,
renderOrder: 58,
});
vehicleSystems.roofDepthMask = addTwinGlassDepthMask(THREE, vehicleSystems.roofGlass);
configureTwinSolidEntity(THREE, vehicleSystems.roofMeshes.slice(1), {
transparent: true,
opacity: 0.88,
depthWrite: false,
renderOrder: 59,
});
configureTwinSolidEntity(THREE, vehicleSystems.roofShade, {
transparent: true,
opacity: 0.08,
renderOrder: 49,
});
configureTwinSolidEntity(THREE, vehicleSystems.headlampMeshes, {
transparent: false,
opacity: 1,
depthWrite: true,
renderOrder: 57,
});
const models = {
passenger: createTwinSeat(THREE, -1.05, 0x4a78af),
driver: createTwinSeat(THREE, 1.05, 0x08766e),
};
Object.entries(models).forEach(([seat, model]) => {
configureActivePassengerSeatStyle(model);
model.root.scale.setScalar(0.5);
model.root.position.set(seat === "driver" ? 0.62 : -0.62, 0.38, 0.25);
model.root.userData.baseRootY = model.root.position.y;
model.root.userData.twinRole = `active_front_${seat}`;
configureTwinSolidEntity(THREE, model.root, { renderOrder: 51 });
configureActiveSeatDepthLayers(THREE, model);
scene.add(model.root);
});
const fixedModels = {
rear: createTwinRearBench(THREE),
};
applyTwinOutlineStyle(THREE, fixedModels.rear, {
color: 0x5e7479,
lineOpacity: 0.42,
fillOpacity: 0.01,
renderOrder: 41,
thresholdAngle: 26,
});
vehicleSystems.root.add(fixedModels.rear);
[
vehicleSystems.root,
models.passenger.root,
models.driver.root,
fixedModels.rear,
].forEach((root) => {
root.traverse((object) => object.layers.set(1));
});
const initialDevice = () => ({
ventilation: { state: "off", level: 0 },
heating: { state: "off", level: 0 },
position: Object.fromEntries(Object.entries(TWIN_DEFAULT_POSITIONS).map(([axis, position_pct]) => [axis, { position_pct, fault: false }])),
});
state.seatTwin = {
renderer,
scene,
camera,
models,
fixedModels,
vehicleSystems,
selected: "passenger",
actuatorState: { passenger: initialDevice(), driver: initialDevice() },
explicitAngleTargets: { passenger: {}, driver: {} },
view: { yaw: -0.62, pitch: 0.31, distance: 10.2, targetX: 0 },
dragging: false,
pointer: { x: 0, y: 0 },
};
applyTwinVehicleTheme(vehicleSystems);
window.addEventListener("cognitive-agent:demo-role-change", (event) => {
applyTwinVehicleTheme(vehicleSystems, event.detail?.role);
});
loadReferenceVehicleModel(THREE, scene, vehicleSystems, fallback)
.then(({ vehicle, report, normalized, driverCushion, passengerCushion }) => {
if (!state.seatTwin) return;
state.seatTwin.referenceVehicle = vehicle;
state.seatTwin.referenceReport = report;
state.seatTwin.referenceBounds = normalized.bounds;
fixedModels.rear.visible = false;
[["passenger", passengerCushion], ["driver", driverCushion]].forEach(([seat, referenceCushion]) => {
if (!referenceCushion) return;
const cushionBoundsBefore = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
const verticalCorrection = THREE.MathUtils.clamp(
referenceCushion.surfaceY - cushionBoundsBefore.max.y,
-0.08,
0.42,
);
models[seat].root.position.y += verticalCorrection;
models[seat].root.userData.baseRootY = models[seat].root.position.y;
models[seat].root.updateMatrixWorld(true);
const cushionBoundsAfter = visibleTwinMeshBounds(THREE, models[seat].cushionPivot);
models[seat].root.userData.cushionSurfaceY = cushionBoundsAfter.max.y;
canvas.dataset[`${seat}SeatCushionAligned`] = String(
Math.abs(cushionBoundsAfter.max.y - referenceCushion.surfaceY) < 0.025
);
});
models.passenger.root.updateMatrixWorld(true);
const activeSeatBounds = new THREE.Box3().setFromObject(models.passenger.root);
const activeSeatSize = activeSeatBounds.getSize(new THREE.Vector3());
const activeSeatContained = (
normalized.bounds.containsPoint(activeSeatBounds.min)
&& normalized.bounds.containsPoint(activeSeatBounds.max)
);
canvas.dataset.activeSeatContained = String(activeSeatContained);
canvas.dataset.activeSeatSize = activeSeatSize
.toArray()
.map((value) => value.toFixed(2))
.join("x");
canvas.dataset.referenceModelLoaded = "true";
fallback.hidden = true;
})
.catch((error) => {
console.error("[vehicle-twin] failed to load reference vehicle model", error);
fallback.classList.add("error");
fallback.innerHTML = '实车模型加载失败,已切换轻量回退模型';
refreshIcons();
window.setTimeout(() => { fallback.hidden = true; }, 3200);
});
function resetView() {
Object.assign(state.seatTwin.view, {
yaw: -0.62,
pitch: 0.31,
distance: 10.2,
targetX: 0,
});
}
function resize() {
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
state.seatTwin.narrowViewport = width < 600;
}
new ResizeObserver(resize).observe(stage);
resize();
stage.addEventListener("pointerdown", (event) => {
state.seatTwin.dragging = true;
state.seatTwin.pointer = { x: event.clientX, y: event.clientY };
stage.setPointerCapture(event.pointerId);
stage.classList.add("dragging");
});
stage.addEventListener("pointermove", (event) => {
const twin = state.seatTwin;
if (!twin?.dragging) return;
const dx = event.clientX - twin.pointer.x;
const dy = event.clientY - twin.pointer.y;
twin.pointer = { x: event.clientX, y: event.clientY };
twin.view.yaw -= dx * 0.006;
twin.view.pitch = Math.max(0.12, Math.min(0.68, twin.view.pitch + dy * 0.004));
});
const endDrag = () => {
if (!state.seatTwin) return;
state.seatTwin.dragging = false;
stage.classList.remove("dragging");
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
stage.addEventListener("wheel", (event) => {
event.preventDefault();
const twin = state.seatTwin;
twin.view.distance = Math.max(9.4, Math.min(15.2, twin.view.distance + event.deltaY * 0.006));
}, { passive: false });
$("#twinReset").addEventListener("click", resetView);
$$(".twin-seat-button").forEach((button) => button.addEventListener("click", () => setTwinSelection(button.dataset.twinSeat)));
const degrees = THREE.MathUtils.degToRad;
const projectedPosition = new THREE.Vector3();
function positionTwinCallouts() {
const twin = state.seatTwin;
const model = twin.models[twin.selected];
const width = canvas.clientWidth;
const height = canvas.clientHeight;
scene.updateMatrixWorld(true);
camera.updateMatrixWorld();
Object.entries(model.anchors).forEach(([axis, anchor]) => {
anchor.getWorldPosition(projectedPosition);
projectedPosition.project(camera);
const callout = $(`[data-callout="${axis}"]`);
const includedAxis = {
backrest_included_angle: "backrest_recline",
legrest_included_angle: "legrest_height",
}[axis];
const supportedAxes = twinAxesForSeat(twin.selected);
const supported = supportedAxes.has(includedAxis || axis);
const requested = (
supported
&& (!includedAxis
|| twin.explicitAngleTargets[twin.selected]?.[includedAxis] != null)
);
const visible = (
requested
&& projectedPosition.z > -1
&& projectedPosition.z < 1
);
callout.hidden = !visible;
if (!visible) return;
const anchorX = (projectedPosition.x * 0.5 + 0.5) * width;
const anchorY = (-projectedPosition.y * 0.5 + 0.5) * height;
const offset = {
longitudinal: { x: -104, y: 50 },
backrest_recline: { x: -108, y: -28 },
cushion_tilt: { x: 106, y: -4 },
legrest_height: { x: 112, y: 52 },
backrest_included_angle: { x: -112, y: 42 },
legrest_included_angle: { x: 114, y: 78 },
}[axis] || { x: 0, y: 0 };
const halfWidth = Math.max(46, callout.offsetWidth / 2);
const halfHeight = Math.max(22, callout.offsetHeight / 2);
const x = Math.max(halfWidth + 10, Math.min(width - halfWidth - 10, anchorX + offset.x));
const y = Math.max(halfHeight + 10, Math.min(height - halfHeight - 10, anchorY + offset.y));
callout.style.left = `${x}px`;
callout.style.top = `${y}px`;
const dx = anchorX - x;
const dy = anchorY - y;
const distance = Math.max(1, Math.hypot(dx, dy));
const unitX = dx / distance;
const unitY = dy / distance;
const horizontalBoundary = Math.abs(unitX) > 0.001
? halfWidth / Math.abs(unitX)
: Number.POSITIVE_INFINITY;
const verticalBoundary = Math.abs(unitY) > 0.001
? halfHeight / Math.abs(unitY)
: Number.POSITIVE_INFINITY;
const startDistance = Math.min(distance, Math.min(horizontalBoundary, verticalBoundary) + 3);
callout.style.setProperty("--leader-start-x", `${unitX * startDistance}px`);
callout.style.setProperty("--leader-start-y", `${unitY * startDistance}px`);
callout.style.setProperty("--leader-length", `${Math.max(0, distance - startDistance - 2)}px`);
callout.style.setProperty("--leader-angle", `${Math.atan2(dy, dx)}rad`);
callout.style.setProperty("--leader-dx", `${dx}px`);
callout.style.setProperty("--leader-dy", `${dy}px`);
});
}
function animate(timestamp = 0) {
const twin = state.seatTwin;
if (!twin) return;
Object.values(twin.models).forEach((model) => {
Object.keys(model.positions).forEach((axis) => {
const delta = model.targets[axis] - model.positions[axis];
model.positions[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
});
Object.keys(model.angles).forEach((axis) => {
const delta = model.angleTargets[axis] - model.angles[axis];
model.angles[axis] += Math.abs(delta) < 0.03 ? delta : delta * 0.085;
});
model.slide.position.z = (model.positions.longitudinal - 50) * 0.012;
model.root.position.y = Number(model.root.userData.baseRootY ?? 0.38)
+ (model.positions.height - 50) * 0.003;
const cushionAngle = model.positions.cushion_tilt * 0.09;
model.cushionPivot.rotation.x = degrees(-cushionAngle);
model.backrestPivot.rotation.x = (
model.cushionPivot.rotation.x
- degrees(model.angles.backrest_recline - 90)
);
model.legrestPivot.rotation.x = degrees(
180 - cushionAngle - model.angles.legrest_height,
);
model.legrestExtension.position.z = model.positions.legrest_extension * 0.0022;
updateTwinAngleGuide(
model.angleGuides.backrest_recline,
model.outerSide * 0.83,
0.62,
-0.5,
cushionAngle,
model.angles.backrest_recline,
0.58,
);
updateTwinAngleGuide(
model.angleGuides.legrest_height,
-model.outerSide * 0.7,
0.64,
0.7,
cushionAngle + 180,
model.angles.legrest_height,
0.48,
);
updateTwinThermalVisual(model, timestamp);
});
updateTwinVehicleVisual(twin.vehicleSystems, timestamp);
const { view } = twin;
// Fit the whole reference vehicle against the limiting horizontal field
// of view. The desktop twin is often almost portrait-shaped, where a
// fixed narrow-viewport multiplier still clips the front bumper.
const viewportFit = THREE.MathUtils.clamp(
1.24 / Math.max(0.72, camera.aspect),
1,
1.4,
);
const cameraDistance = view.distance * viewportFit;
const horizontal = Math.cos(view.pitch) * cameraDistance;
const cameraTargetX = view.targetX;
camera.position.set(
cameraTargetX + Math.sin(view.yaw) * horizontal,
0.82 + Math.sin(view.pitch) * cameraDistance,
Math.cos(view.yaw) * horizontal,
);
camera.lookAt(cameraTargetX, 1.14, -0.08);
updateReferenceWindowOutlineLayers(twin.vehicleSystems, camera);
positionTwinCallouts();
renderer.clear();
camera.layers.set(0);
renderer.render(scene, camera);
renderer.clearDepth();
const sceneBackground = scene.background;
scene.background = null;
camera.layers.set(1);
renderer.render(scene, camera);
scene.background = sceneBackground;
camera.layers.set(0);
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
window.requestAnimationFrame(animate);
}
setTwinSelection("passenger");
animate();
}
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
}
function isLocalDecisionHost() {
return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
}
function isDriverOnlyCh1Runtime() {
return state.runtime?.vehicle_can?.configured === true
&& state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
&& state.runtime?.hardware?.configured !== true;
}
function renderHardwareAccess(status) {
state.hardwareAccess = status;
}
async function loadHardwareAccessStatus({ silent = false } = {}) {
try {
const query = new URLSearchParams({ client_id: state.hardwareClientId });
const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
if (response.status === 401) return;
const payload = await response.json();
if (!response.ok) throw new Error(payload.message || payload.error);
renderHardwareAccess(payload);
} catch (error) {
if (!silent) showToast(`使用权状态读取失败:${error.message}`);
}
}
function selectProvider(provider) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
button.classList.toggle("active", button.dataset.provider === provider);
});
}
function updateSeatFeedbackModeLabel() {
const label = $("#seatFeedbackModeLabel");
const descriptions = {
physical_can: "实车已同步",
physical_partial: "物理反馈不完整",
physical_can_ch1: "实车已同步",
simulated_hardware: "模拟 HIL",
simulated: "虚拟反馈",
unavailable: "不可用",
};
const compactDescriptions = {
physical_can: "实车",
physical_partial: "部分",
physical_can_ch1: "实车",
simulated_hardware: "模拟",
simulated: "虚拟",
unavailable: "未连接",
};
const channels = { passenger: "CH0", driver: "CH1" };
Object.entries(channels).forEach(([seat, channel]) => {
const source = state.seatFeedbackSources[seat] || "unavailable";
const badge = $(`[data-seat-feedback="${seat}"]`);
if (!badge) return;
badge.dataset.source = source;
badge.textContent = `${channel} · ${compactDescriptions[source] || source}`;
badge.title = `${channel} · ${descriptions[source] || source}`;
});
if (label) label.textContent = `CH0 副驾 · ${descriptions[state.seatFeedbackSources.passenger]} / CH1 主驾 · ${descriptions[state.seatFeedbackSources.driver]}`;
}
function selectExecutionMode(mode) {
if (mode === "hardware" && !state.runtime?.hardware_execution_allowed) return;
if (mode === "simulated_hardware" && !state.runtime?.simulated_hardware?.enabled) return;
state.executionMode = mode;
$$('[data-execution-mode]').forEach((button) => {
button.classList.toggle("active", button.dataset.executionMode === mode);
});
const hardware = mode === "hardware";
const simulatedHardware = mode === "simulated_hardware";
const vehicleCanReady = Boolean(state.runtime?.vehicle_can?.configured);
const ch1DriverOnly = isDriverOnlyCh1Runtime();
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
? 'HIL · 真实 CAN'
: simulatedHardware
? 'SIM · 模拟台架'
: 'DRY-RUN';
$("#safetyTitle").textContent = hardware
? (ch1DriverOnly ? "CH1 实车安全门禁" : "真实台架安全门禁")
: simulatedHardware ? "模拟台架隔离边界" : "虚拟执行边界";
$("#safetyText").textContent = hardware
? ch1DriverOnly && vehicleCanReady
? "仅使用 ZLG CH1 控制主驾座椅与已验证车身设备;每次动作前必须确认 P 挡和 0 km/h,并以 CH1 状态帧回读为准。CH0、副驾 T2G 与 IAR 路径均不参与。"
: vehicleCanReady
? "同一 CAN 盒由单进程持有:CH0 控制副驾四电机并校验板端反馈;CH1 控制主驾四电机与车身设备,每次动作前校验 P 挡、零车速和对应状态帧。"
: "当前仅通道 0 连接副驾座椅台架并校验板端反馈;车端 CAN 未启用,模式中的其他车舱领域保持虚拟执行。"
: simulatedHardware
? "故障只注入执行报告;物理 CAN、ZLG 适配器和 IAR 固件路径保持关闭。"
: "四域 CAN 均为 dry-run,真实发送被强制关闭。";
$("#executeStageLabel").textContent = hardware ? "台架执行" : simulatedHardware ? "故障仿真" : "虚拟执行";
$("#feedbackModeLabel").textContent = hardware
? (ch1DriverOnly
? "CH1 BODY CAN · AUTHORITATIVE PHYSICAL FEEDBACK"
: "T2G BENCH · PHYSICAL CAN FEEDBACK")
: simulatedHardware
? "SIMULATED HIL · FAULT INJECTION"
: "MATRIX-BACKED · SIMULATED FEEDBACK";
const runButtonLabel = $("#runButton span");
if (runButtonLabel) {
runButtonLabel.textContent = hardware
? "真实执行"
: simulatedHardware ? "运行故障仿真" : "生成并执行";
}
updateSeatFeedbackModeLabel();
refreshIcons();
}
function vehicleProfiles() {
return Array.isArray(state.runtime?.vehicles) ? state.runtime.vehicles : [];
}
function findVehicleProfile(vehicleId) {
return vehicleProfiles().find((vehicle) => vehicle.id === vehicleId)
|| state.runtime?.vehicle
|| null;
}
function applyTwinAngleRanges(vehicle) {
Object.keys(TWIN_ANGLE_RANGES).forEach((axis) => delete TWIN_ANGLE_RANGES[axis]);
const motors = vehicle?.seat?.motors || {};
Object.entries(motors).forEach(([axis, motor]) => {
const display = motor?.display_angle;
if (!display) return;
const zero = Number(display.zero_deg);
const maximum = Number(display.max_deg);
if (Number.isFinite(zero) && Number.isFinite(maximum) && maximum > zero) {
TWIN_ANGLE_RANGES[axis] = [zero, maximum];
}
});
}
function applySeatMotorAvailability(vehicle) {
const motors = vehicle?.seat?.motors || {};
const availableAxes = new Set(Object.keys(motors));
state.availableSeatAxes = availableAxes;
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
const available = availableAxes.has(axis) || axis === "height";
$$(`[data-axis="${axis}"], [data-twin-axis="${axis}"], [data-callout="${axis}"]`)
.forEach((item) => {
item.classList.toggle("seat-axis-unavailable", !available);
item.setAttribute("aria-hidden", String(!available));
});
const twinLabel = {
longitudinal: "水平位置",
cushion_tilt: "坐垫俯仰",
height: "座椅高度",
backrest_recline: "靠背夹角",
legrest_height: "腿托夹角",
legrest_extension: "腿托伸缩",
}[axis];
$$(`[data-twin-axis="${axis}"] > span, [data-callout="${axis}"] > span`)
.forEach((label) => { label.textContent = twinLabel; });
});
const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span');
const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span');
if (backrestIncludedLabel) backrestIncludedLabel.textContent = "靠背-坐垫夹角";
if (legrestIncludedLabel) legrestIncludedLabel.textContent = "坐垫-腿托夹角";
}
function renderSeatMotorRows(vehicle) {
$$(".seat-card .motion-state-grid").forEach((grid) => {
const seat = grid.closest(".seat-card")?.dataset.seat;
const motors = seat === "driver"
? [
["longitudinal", { name: "水平" }],
["height", { name: "高度" }],
["backrest_recline", { name: "靠背" }],
["cushion_tilt", { name: "盆倾" }],
]
: Object.entries(vehicle?.seat?.motors || {});
const fragment = document.createDocumentFragment();
motors.forEach(([axis, motor]) => {
const item = document.createElement("div");
item.className = "motion-axis";
item.dataset.axis = axis;
const label = document.createElement("span");
label.textContent = {
longitudinal: "水平", cushion_tilt: "坐垫", height: "高度",
backrest_recline: "靠背", legrest_height: "腿托", legrest_extension: "伸缩",
}[axis] || motor?.name || axis;
const value = document.createElement("b");
value.textContent = "--";
item.append(label, value, document.createElement("i"));
fragment.append(item);
});
grid.replaceChildren(fragment);
});
}
function selectVehicle(vehicleId, { silent = false } = {}) {
const vehicle = findVehicleProfile(vehicleId);
if (!vehicle) return;
state.vehicleId = vehicle.id;
const select = $("#vehicleSelect");
if (select) select.value = vehicle.id;
applyTwinAngleRanges(vehicle);
renderSeatMotorRows(vehicle);
applySeatMotorAvailability(vehicle);
const longitudinal = vehicle.seat?.motors?.longitudinal;
const backrest = vehicle.seat?.motors?.backrest_recline;
$("#vehicleNote").textContent = longitudinal && backrest
? `${vehicle.name} · 水平 ${longitudinal.range}${longitudinal.unit} · 靠背 ${backrest.range}${backrest.unit}`
: `${vehicle.name} · 已加载车型配置`;
if (isDriverOnlyCh1Runtime()) {
if (state.vehicleStateSnapshot?.actuatorState) {
renderSeatState(state.vehicleStateSnapshot.actuatorState);
}
} else if (state.result?.actuator_state) {
renderSeatState(state.result.actuator_state);
} else if (state.seatTwin?.actuatorState) {
renderSeatState(state.seatTwin.actuatorState);
}
if (!silent) showToast(`已切换车型:${vehicle.name}`);
}
function populateVehicleSelector() {
const select = $("#vehicleSelect");
if (!select) return;
const vehicles = vehicleProfiles();
select.innerHTML = vehicles.length
? vehicles.map((vehicle) => ``).join("")
: '';
const defaultVehicleId = state.runtime?.default_vehicle_id
|| state.runtime?.vehicle?.id
|| vehicles[0]?.id
|| "";
select.disabled = vehicles.length === 0;
if (defaultVehicleId) selectVehicle(defaultVehicleId, { silent: true });
}
function modeProfiles() {
return Array.isArray(state.runtime?.modes) ? state.runtime.modes : [];
}
function modeChipLabel(mode) {
const name = String(mode?.name || mode?.id || "模式");
return name.endsWith("模式") && name.length > 2
? name.slice(0, -2)
: name;
}
function modeSecondsLabel(durationMs) {
const seconds = Number(durationMs) / 1000;
return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
}
const MODE_GALLERY_GROUPS = [
{
id: "personal",
title: "我的模式",
description: "根据自然语言指令创建,可执行或删除",
icon: "bookmark",
modeIds: [],
userOnly: true,
},
{
id: "frequent",
title: "常用模式",
description: "高频座舱场景,可从主界面快捷执行",
icon: "sparkles",
modeIds: ["zero_gravity", "office", "nap", "queen_passenger_driving", "leisure_lounger"],
},
{
id: "comfort",
title: "舒享模式",
description: "休憩、冥想与乘坐舒适场景",
icon: "armchair",
modeIds: [
"cruise_sofa",
"easy_entry_exit",
"electric_beach_chair",
"calf_decompression_float",
"baby_nap",
"meditation_space",
],
},
{
id: "dynamic",
title: "动态模式",
description: "按受控节拍持续调整座椅姿态",
icon: "waves",
modeIds: ["rocking_horse", "dynamic_cradle"],
},
];
const MODE_DOMAIN_LABELS = {
seat: "座椅",
climate: "空调",
roof: "天幕",
ambient_light: "氛围灯",
window: "车窗",
front_light: "车灯",
vehicle_status: "车辆状态",
};
function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true;
}
function modeGalleryBuckets() {
const modes = modeProfiles();
const assignedIds = new Set(MODE_GALLERY_GROUPS.flatMap((group) => group.modeIds));
return MODE_GALLERY_GROUPS.map((definition) => {
let groupModes;
if (definition.userOnly) {
groupModes = modes.filter(isUserModeProfile);
} else if (definition.id === "comfort") {
groupModes = definition.modeIds
.map((modeId) => modes.find((mode) => mode.id === modeId))
.filter(Boolean);
modes.filter((mode) => !isUserModeProfile(mode) && !assignedIds.has(mode.id))
.forEach((mode) => groupModes.push(mode));
} else {
groupModes = definition.modeIds
.map((modeId) => modes.find((mode) => mode.id === modeId))
.filter(Boolean);
}
return { ...definition, modes: groupModes };
}).filter((group) => group.modes.length > 0 || group.userOnly);
}
function createModeGalleryCard(mode) {
const shell = document.createElement("article");
shell.className = "mode-gallery-card-shell";
shell.dataset.modeOrigin = isUserModeProfile(mode) ? "user" : "preset";
const button = document.createElement("button");
button.className = "mode-gallery-card";
button.type = "button";
button.dataset.modeProfileId = mode.id;
button.dataset.modeCommand = `进入${mode.name}`;
button.dataset.modeOrigin = isUserModeProfile(mode) ? "user" : "preset";
button.dataset.modeState = "idle";
button.setAttribute("aria-pressed", "false");
const iconBox = document.createElement("span");
iconBox.className = "mode-gallery-card-icon";
const icon = document.createElement("i");
icon.dataset.lucide = mode.icon || "bookmark";
iconBox.append(icon);
const copy = document.createElement("span");
copy.className = "mode-gallery-card-copy";
const heading = document.createElement("span");
heading.className = "mode-gallery-card-heading";
const name = document.createElement("strong");
name.textContent = mode.name || mode.id;
const stateLabel = document.createElement("small");
stateLabel.className = "mode-gallery-state";
stateLabel.textContent = "点击执行";
heading.append(name, stateLabel);
const description = document.createElement("span");
description.className = "mode-gallery-card-description";
description.textContent = mode.description || "整车场景联动配置";
const meta = document.createElement("span");
meta.className = "mode-gallery-card-meta";
const domains = Array.isArray(mode.domains) ? mode.domains : [];
const domainCopy = domains.slice(0, 4)
.map((domain) => MODE_DOMAIN_LABELS[domain] || domain)
.join(" · ");
meta.textContent = domainCopy || `${Number(mode.operation_count || 0)} 项联动动作`;
copy.append(heading, description, meta);
button.append(iconBox, copy);
shell.append(button);
if (isUserModeProfile(mode)) {
const deleteButton = document.createElement("button");
deleteButton.className = "mode-gallery-delete";
deleteButton.type = "button";
deleteButton.dataset.modeDelete = mode.id;
deleteButton.title = `删除${mode.name}`;
deleteButton.setAttribute("aria-label", `删除自定义功能模式${mode.name}`);
const deleteIcon = document.createElement("i");
deleteIcon.dataset.lucide = "trash-2";
deleteButton.append(deleteIcon);
shell.append(deleteButton);
}
return shell;
}
function populateModeGallery() {
const container = $("#modeGalleryGroups");
if (!container) return;
container.replaceChildren();
modeGalleryBuckets().forEach((group) => {
const section = document.createElement("section");
section.className = "mode-gallery-group";
section.dataset.modeGroup = group.id;
const header = document.createElement("header");
const title = document.createElement("span");
const titleIcon = document.createElement("i");
titleIcon.dataset.lucide = group.icon;
const titleCopy = document.createElement("span");
const strong = document.createElement("strong");
strong.textContent = group.title;
const small = document.createElement("small");
small.textContent = group.description;
titleCopy.append(strong, small);
title.append(titleIcon, titleCopy);
const count = document.createElement("b");
count.textContent = String(group.modes.length);
header.append(title, count);
section.append(header);
const grid = document.createElement("div");
grid.className = "mode-gallery-grid";
if (group.modes.length) {
group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
} else {
const empty = document.createElement("div");
empty.className = "mode-gallery-empty";
empty.innerHTML = '还没有自定义模式可在主界面根据当前指令创建';
grid.append(empty);
}
section.append(grid);
container.append(section);
});
const modeCount = modeProfiles().length;
$("#modeGalleryCount").textContent = String(modeCount);
$("#modeGallerySummaryCount").textContent = String(modeCount);
refreshIcons();
}
function updateModeRailNavigation() {
const chips = $(".mode-chips");
const previous = $("#modeRailPrev");
const next = $("#modeRailNext");
if (!chips || !previous || !next) return;
const maxScroll = Math.max(0, chips.scrollWidth - chips.clientWidth);
previous.disabled = maxScroll <= 2 || chips.scrollLeft <= 2;
next.disabled = maxScroll <= 2 || chips.scrollLeft >= maxScroll - 2;
}
function scrollModeRail(direction) {
const chips = $(".mode-chips");
if (!chips) return;
const buttons = $$("button[data-mode-profile-id]", chips);
const viewport = chips.getBoundingClientRect();
const target = direction > 0
? buttons.find((button) => button.getBoundingClientRect().right > viewport.right + 2)
: [...buttons].reverse().find((button) => button.getBoundingClientRect().left < viewport.left - 2);
if (target) {
const targetLeft = direction > 0
? target.offsetLeft - 6
: target.offsetLeft + target.offsetWidth - chips.clientWidth + 6;
chips.scrollTo({ left: Math.max(0, targetLeft), behavior: "smooth" });
} else {
chips.scrollBy({ left: direction * Math.max(220, chips.clientWidth * 0.72), behavior: "smooth" });
}
}
function openModeGallery() {
const backdrop = $("#modeGalleryBackdrop");
if (!backdrop) return;
closeSaveModeComposer();
backdrop.hidden = false;
document.body.classList.add("mode-gallery-open");
$("#modeGalleryToggle").setAttribute("aria-expanded", "true");
window.requestAnimationFrame(() => {
backdrop.classList.add("open");
$("#modeGalleryClose").focus();
});
syncReasoningControls();
}
function availableReasoningEfforts() {
const configured = state.runtime?.openai_reasoning_efforts;
return Array.isArray(configured) && configured.length ? configured : ["low", "medium", "high"];
}
function selectReasoningEffort(effort, { persist = true } = {}) {
if (!availableReasoningEfforts().includes(effort)) return false;
state.reasoningEffort = effort;
if (persist) localStorage.setItem("cognitive-agent.reasoning-effort", effort);
syncReasoningControls();
return true;
}
function syncReasoningControls() {
const enabled = state.provider === "openai" && state.runtime?.openai_available === true;
const descriptions = {
low: "极速模式优先降低指令解析延迟",
medium: "均衡模式兼顾自然语言解析精度与响应速度",
high: "深度模式适合多设备、相对动作与复杂约束指令",
};
$$('[data-reasoning-effort]').forEach((button) => {
button.disabled = !enabled || !availableReasoningEfforts().includes(button.dataset.reasoningEffort);
button.classList.toggle("active", button.dataset.reasoningEffort === state.reasoningEffort);
});
const note = $("#reasoningNote");
if (note) note.textContent = enabled ? descriptions[state.reasoningEffort] : "本地规则解析不调用模型推理";
}
function closeModeGallery({ restoreFocus = true } = {}) {
const backdrop = $("#modeGalleryBackdrop");
if (!backdrop || backdrop.hidden) return;
backdrop.classList.remove("open");
backdrop.hidden = true;
document.body.classList.remove("mode-gallery-open");
$("#modeGalleryToggle").setAttribute("aria-expanded", "false");
if (restoreFocus) $("#modeGalleryToggle").focus();
}
function populateModeChips() {
const group = $(".mode-chips");
if (!group) return;
const buttons = () => $$("button", group);
const modes = modeProfiles();
const runtimeModeIds = new Set(modes.map((mode) => mode.id));
$$('button[data-mode-origin="user"]', group).forEach((button) => {
if (!runtimeModeIds.has(button.dataset.modeProfileId)) button.remove();
});
modes.forEach((mode) => {
const label = modeChipLabel(mode);
let button = buttons().find((item) => item.dataset.modeProfileId === mode.id)
|| buttons().find((item) => item.textContent.trim() === label);
if (!button) {
button = document.createElement("button");
button.type = "button";
const icon = document.createElement("i");
icon.dataset.lucide = mode.icon || "bookmark";
const labelNode = document.createElement("span");
labelNode.className = "mode-chip-label";
labelNode.textContent = label;
button.append(icon, labelNode);
group.append(button);
}
let labelNode = $(".mode-chip-label", button) || $("span", button);
if (!labelNode) {
labelNode = document.createElement("span");
button.append(labelNode);
}
labelNode.classList.add("mode-chip-label");
labelNode.textContent = label;
button.dataset.modeProfileId = mode.id;
button.dataset.modeCommand = `进入${mode.name}`;
button.dataset.modeOrigin = isUserModeProfile(mode)
? "user"
: "preset";
button.setAttribute("aria-pressed", "false");
const timeline = mode.timeline;
let cycleBadge = $(".mode-cycle-badge", button);
if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) {
if (!cycleBadge) {
cycleBadge = document.createElement("small");
cycleBadge.className = "mode-cycle-badge";
button.append(cycleBadge);
}
const runtimeMinutes = Number(timeline.max_runtime_ms || 0) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
const cycleDurationMs = Number(timeline.cycle_duration_ms || 0);
const cycleLabel = cycleDurationMs > 0
? `${modeSecondsLabel(cycleDurationMs)}s循环`
: "目标到位循环";
cycleBadge.textContent = runtimeMinutes > 0
? `${cycleLabel} · 最长${runtimeLabel}min`
: cycleLabel;
button.dataset.modeBehavior = "cyclic";
} else if (cycleBadge) {
cycleBadge.remove();
}
const phaseSummary = Array.isArray(timeline?.phases)
? timeline.phases.map((phase) => {
if (Number(phase.duration_ms || 0) > 0) {
return `${phase.name} ${modeSecondsLabel(phase.duration_ms)}s`;
}
const timeout = Number(phase.timeout_ms || 0);
return timeout > 0
? `${phase.name} 目标到位(保护上限${modeSecondsLabel(timeout)}s)`
: `${phase.name} 目标到位`;
}).join(" → ")
: "";
button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n");
});
const preferredStaticOrder = [
"zero_gravity",
"office",
"nap",
"queen_passenger_driving",
"leisure_lounger",
];
const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
const allButtons = buttons();
const userButtons = allButtons.filter((button) => button.dataset.modeOrigin === "user");
const dynamicButtons = dynamicModeOrder
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
const staticButtons = allButtons.filter(
(button) => !userButtons.includes(button) && !dynamicButtons.includes(button),
);
const preferredStaticButtons = preferredStaticOrder
.map((modeId) => staticButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
const otherStaticButtons = staticButtons.filter(
(button) => !preferredStaticButtons.includes(button),
);
$$(".mode-group-divider", group).forEach((divider) => divider.remove());
userButtons.forEach((button) => group.append(button));
if (userButtons.length && staticButtons.length) {
const userDivider = document.createElement("span");
userDivider.className = "mode-group-divider";
userDivider.dataset.modeDivider = "user";
userDivider.setAttribute("aria-hidden", "true");
group.append(userDivider);
}
preferredStaticButtons.forEach((button) => group.append(button));
otherStaticButtons.forEach((button) => group.append(button));
if (dynamicButtons.length && staticButtons.length) {
const divider = document.createElement("span");
divider.className = "mode-group-divider";
divider.setAttribute("aria-hidden", "true");
group.append(divider);
}
dynamicButtons.forEach((button) => group.append(button));
if (userButtons.length) group.scrollLeft = 0;
populateModeGallery();
refreshIcons();
window.requestAnimationFrame(updateModeRailNavigation);
}
async function deleteUserModeProfile(modeProfileId) {
const mode = modeProfiles().find((item) => item.id === modeProfileId);
if (!mode || !isUserModeProfile(mode)) return;
if (!window.confirm(`删除自定义功能模式“${mode.name}”?删除后无法恢复。`)) return;
const deleteButtons = $$(`[data-mode-delete="${CSS.escape(modeProfileId)}"]`);
deleteButtons.forEach((button) => {
button.disabled = true;
button.setAttribute("aria-busy", "true");
});
try {
const query = new URLSearchParams({
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id || "",
});
const response = await apiRequest(
`/api/mode-profiles/${encodeURIComponent(modeProfileId)}?${query}`,
{ method: "DELETE" },
);
const payload = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
await loadRuntime();
showToast(`${mode.name}已删除`);
} catch (error) {
showToast(`删除失败:${error.message || "服务不可用"}`);
deleteButtons.forEach((button) => {
button.disabled = false;
button.removeAttribute("aria-busy");
});
}
}
function setModeExecutionState(modeProfileId, modeState = "idle") {
window.clearTimeout(state.modeRunTimer);
state.modeRunTimer = null;
$$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => {
const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle";
button.classList.toggle("running", active && modeState === "running");
button.classList.toggle("succeeded", active && modeState === "succeeded");
button.classList.toggle("failed", active && modeState === "failed");
button.dataset.modeState = active ? modeState : "idle";
button.setAttribute("aria-pressed", active ? "true" : "false");
button.setAttribute("aria-busy", active && modeState === "running" ? "true" : "false");
const stateLabel = $(".mode-gallery-state", button);
if (stateLabel) {
stateLabel.textContent = active && modeState === "running"
? "执行中"
: active && modeState === "succeeded"
? "已完成"
: active && modeState === "failed"
? "执行失败"
: "点击执行";
}
});
if (modeProfileId && ["succeeded", "failed"].includes(modeState)) {
state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800);
}
}
function setModeChipsDisabled(disabled) {
$$(".mode-chips button[data-mode-command], #modeGallery button[data-mode-command]").forEach((button) => {
button.disabled = disabled;
});
}
async function applyModeCommand(command, modeProfileId = null) {
if (!command) return;
if ($("#runButton").disabled) {
showToast("上一条指令仍在执行,请稍候");
return;
}
if (!modeProfileId) {
$("#utterance").value = command;
$("#requestForm").requestSubmit();
return;
}
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware"
&& !driverOnlyCh1
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
showToast(state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权");
return;
}
closeModeGallery({ restoreFocus: false });
setModeExecutionState(modeProfileId, "running");
setModeChipsDisabled(true);
try {
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
const response = await apiRequest(
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/mode-executions`,
{
method: "POST",
timeoutMs: 10000,
headers: {
"Content-Type": "application/json",
"Idempotency-Key": createOpaqueId("web-mode"),
},
body: JSON.stringify({
mode_id: modeProfileId,
execution_target: state.executionMode,
source: "web",
source_device_id: "web-console",
...(state.executionMode === "hardware" && !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
}),
},
);
const execution = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) throw new Error(execution.message || execution.error || `HTTP ${response.status}`);
state.activeModeExecutions.add(execution.execution_id);
showToast(execution.message || `${modeChipLabel({ name: command })}已进入执行队列`);
} catch (error) {
setModeExecutionState(modeProfileId, "failed");
setModeChipsDisabled(false);
showToast(`模式提交失败:${error.message || "服务不可用"}`);
}
}
function webModeState(status) {
if (["queued", "validating", "executing", "verifying"].includes(status)) return "running";
if (["succeeded", "partially_succeeded"].includes(status)) return "succeeded";
return "failed";
}
function modeExecutionTerminal(status) {
return ["succeeded", "partially_succeeded", "failed", "cancelled", "timed_out"].includes(status);
}
async function handleModeExecutionEvent(event) {
const execution = event?.execution || event;
if (!execution?.execution_id || !execution.mode_id) return;
setModeExecutionState(execution.mode_id, webModeState(execution.status));
if (modeExecutionTerminal(execution.status)) {
state.activeModeExecutions.delete(execution.execution_id);
} else {
state.activeModeExecutions.add(execution.execution_id);
}
setModeChipsDisabled(state.activeModeExecutions.size > 0);
$("#resultTitle").textContent = execution.message || `正在执行${execution.mode_name}`;
if (execution.source === "android") {
showToast(`N7Cockpit:${execution.message || execution.mode_name}`);
}
if (!modeExecutionTerminal(execution.status)
|| state.renderedModeExecutions.has(execution.execution_id)) return;
state.renderedModeExecutions.add(execution.execution_id);
try {
const response = await apiRequest(
`/api/v1/mode-executions/${encodeURIComponent(execution.execution_id)}`,
{ cache: "no-store", timeoutMs: 10000 },
);
const completed = await response.json();
if (response.ok && completed.result) {
renderResult(completed.result);
// renderResult updates the digital twin, but its generic domain summary must not
// replace the stable mode identity required by Web/Android remote operation.
$("#resultTitle").textContent = execution.message || `${execution.mode_name}执行完成`;
$("#requestMeta").textContent = [
execution.execution_id,
`mode:${execution.mode_id}`,
`source:${execution.source === "android" ? "N7Cockpit" : execution.source}`,
execution.execution_target,
].filter(Boolean).join(" / ");
}
} catch (_error) {
// The mode card and status message still contain the terminal state.
}
}
async function pollModeExecutionEvents({ initial = false } = {}) {
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
if (!vehicleId || document.visibilityState === "hidden") return;
try {
const response = await apiRequest(
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/events?after=${state.modeEventSequence}`,
{ cache: "no-store", timeoutMs: 8000 },
);
if (!response.ok) return;
const payload = await response.json();
state.modeEventSequence = Number(payload.next_sequence || state.modeEventSequence);
const events = Array.isArray(payload.events) ? payload.events : [];
for (const event of events) {
const createdAt = Date.parse(event.created_at || "");
if (initial && Number.isFinite(createdAt) && createdAt < state.modeEventStartedAt - 2000) continue;
await handleModeExecutionEvent(event);
}
if (initial && Array.isArray(payload.active_executions)) {
for (const execution of payload.active_executions) await handleModeExecutionEvent(execution);
}
} catch (_error) {
// The normal connection probe remains responsible for the global offline badge.
}
}
function updateModeDraftPreview() {
const command = $("#utterance").value.trim();
state.modeDraftCommand = command;
const source = $("#saveModeSource");
source.textContent = command || "请先在指令框输入需要固化的功能组合";
source.classList.toggle("empty", !command);
}
function toggleSaveModeComposer() {
const composer = $("#saveModeComposer");
composer.hidden = !composer.hidden;
if (!composer.hidden) {
updateModeDraftPreview();
$("#saveModeName").focus();
}
}
function closeSaveModeComposer() {
$("#saveModeComposer").hidden = true;
state.modeDraftCommand = "";
}
function requestCommandAndWait(utterance) {
return new Promise((resolve) => {
const button = $("#runButton");
if (!button || button.disabled) {
resolve({ result: null, error: { message: "上一条指令仍在执行" } });
return;
}
const timeoutMs = requestTimeoutForExecutionMode(state.executionMode) + 5000;
const onComplete = (event) => {
if (event.detail?.utterance !== utterance) return;
window.clearTimeout(timer);
window.removeEventListener("cognitive-agent:request-complete", onComplete);
resolve(event.detail);
};
const timer = window.setTimeout(() => {
window.removeEventListener("cognitive-agent:request-complete", onComplete);
resolve({ result: null, error: { message: "指令执行等待超时" } });
}, timeoutMs);
window.addEventListener("cognitive-agent:request-complete", onComplete);
$("#utterance").value = utterance;
$("#requestForm").requestSubmit();
});
}
async function saveCurrentModeProfile() {
const input = $("#saveModeName");
const rawName = input.value.trim();
if (!rawName) {
showToast("请输入模式名称");
return;
}
const sourceCommand = state.modeDraftCommand || $("#utterance").value.trim();
if (!sourceCommand) {
showToast("请先输入需要创建为模式的用户指令");
$("#utterance").focus();
return;
}
const modeName = rawName.endsWith("模式") ? rawName : `${rawName}模式`;
const confirm = $("#saveModeConfirm");
confirm.disabled = true;
confirm.classList.add("loading");
confirm.textContent = "创建中…";
try {
if (state.lastSuccessfulUtterance !== sourceCommand) {
showToast("正在执行当前指令,成功后将创建功能模式");
const execution = await requestCommandAndWait(sourceCommand);
if (!execution.result || !["succeeded", "partially_succeeded"].includes(execution.result.status)) {
throw new Error(execution.error?.message || "当前指令未成功执行,未创建功能模式");
}
}
const saveCommand = `这个模式不错,固化为${modeName}`;
const saved = await requestCommandAndWait(saveCommand);
if (!saved.result?.mode_profile || saved.result.status !== "succeeded") {
throw new Error(saved.error?.message || "功能模式创建失败");
}
await loadRuntime();
const createdModeId = saved.result.mode_profile.id;
setModeExecutionState(createdModeId, "succeeded");
closeSaveModeComposer();
input.value = "";
$("#utterance").value = sourceCommand;
showToast(`${saved.result.mode_profile.name}已创建并添加到功能模式栏`);
} catch (error) {
showToast(error.message || "功能模式创建失败");
} finally {
confirm.disabled = false;
confirm.classList.remove("loading");
confirm.textContent = "创建并添加";
}
}
function applyRuntimeTargetPolicy(runtime) {
const passengerReady = runtime?.hardware?.configured === true;
const driverReady = runtime?.vehicle_can?.configured === true;
const driverOnly = driverReady && !passengerReady;
const stage = $("#seatTwin");
if (stage) {
stage.dataset.passengerChannel = "CH0";
stage.dataset.driverChannel = "CH1";
stage.dataset.passengerConfigured = String(passengerReady);
stage.dataset.driverConfigured = String(driverReady);
}
['passenger', 'driver'].forEach((seat) => {
const ready = seat === 'passenger' ? passengerReady : driverReady;
const card = $(`.seat-card[data-seat="${seat}"]`);
if (card) card.classList.toggle('hardware-unavailable', !ready);
});
const jointCopy = $(".joint-twin-heading p");
if (jointCopy) jointCopy.textContent = passengerReady && driverReady
? "CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈,并与车身设备联合展示。"
: "前排双座椅保持独立展示;未配置的物理通道会明确标记为不可用。";
if (driverOnly) setTwinSelection("driver");
const input = $("#utterance");
if (input && driverOnly) {
input.placeholder = "例如:打开主驾通风,并把空调温度调到24度";
}
const safeExamples = [
["seat driver vent low", "主驾通风低档"],
["seat driver heat low", "主驾加热低档"],
["ac temp driver 24", "主驾空调 24℃"],
["window front-left position 0", "关闭左前车窗"],
["shade position 0", "关闭遮阳帘"],
["light reading all off", "关闭全部阅读灯"],
];
if (driverOnly) $$(".example-command").forEach((button, index) => {
const example = safeExamples[index];
button.hidden = !example;
if (!example) return;
button.dataset.example = example[0];
const label = $("span", button);
if (label) label.textContent = example[1];
});
const gestureButton = $("#gestureToggleButton");
if (gestureButton && driverOnly) {
gestureButton.disabled = true;
gestureButton.title = "CH1 主驾实车测试不启用无闭环的座椅位置手势";
}
refreshIcons();
}
async function loadRuntime() {
try {
const response = await apiRequest("/api/runtime", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.runtime = await response.json();
applyRuntimeTargetPolicy(state.runtime);
populateVehicleSelector();
populateModeChips();
const configuredEffort = state.runtime.openai_reasoning_effort_default || "medium";
if (!availableReasoningEfforts().includes(state.reasoningEffort)) {
state.reasoningEffort = configuredEffort;
}
const hardware = state.runtime.hardware || {};
const vehicleCan = state.runtime.vehicle_can || {};
const linkedHardwareReady = Boolean(hardware.configured && vehicleCan.configured);
const seatHardwareReady = Boolean(hardware.configured);
const vehicleHardwareReady = Boolean(vehicleCan.configured);
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
const hardwareExecutionReady = Boolean(state.runtime.hardware_execution_allowed);
const badge = $("#modelBadge");
const gptButton = $('[data-provider="openai"]');
if (state.runtime.openai_available) {
badge.classList.remove("danger");
badge.innerHTML = `${state.runtime.openai_model}`;
const responsesEndpoint = state.runtime.openai_responses_endpoint || "Responses API";
$("#providerNote").textContent = `${state.runtime.openai_model} · ${responsesEndpoint} · 失败时自动降级本地规则`;
selectProvider("openai");
} else {
badge.classList.add("danger");
badge.innerHTML = 'GPT 未配置';
gptButton.disabled = true;
gptButton.title = "设置 OPENAI_API_KEY 后启用";
$("#providerNote").textContent = "未设置 OPENAI_API_KEY,已切换为本地规则解析";
selectProvider("local");
}
syncReasoningControls();
$("#matrixCount").textContent = `${state.runtime.matrix.messages} / ${state.runtime.matrix.signals}`;
const hardwareButton = $("#hardwareMode");
hardwareButton.disabled = !hardwareExecutionReady;
const simulation = state.runtime.simulated_hardware || {};
$("#simulationMode").disabled = !simulation.enabled;
$("#simulationMode").title = simulation.enabled
? "开发专用模拟台架,不连接物理 CAN"
: "通过安卓联调脚本或环境变量启用模拟台架";
const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
? " · 动作防冲突"
: "";
const accessText = !ch1DriverOnly
&& hardware.access_policy === "local_approval_idle_lease"
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
: "";
$("#hardwareNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `${vehicleCan.backend} · CH${vehicleCan.channel} 车身 CAN FD ${vehicleCan.arbitration.bitrate / 1000}k/${vehicleCan.data.bitrate / 1000000}M · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}`
: linkedHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · CH${vehicleCan.channel} 车身 CAN FD ${vehicleCan.arbitration.bitrate / 1000}k/${vehicleCan.data.bitrate / 1000000}M · ${profileText} · ${flashText}${accessText}${concurrencyText}`
: seatHardwareReady
? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
: `联动真实执行已锁定:${profileText};请完成座椅固件、双通道 ZLG 适配器和车身 CAN FD 配置`;
$("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? "CH1 → 车身 CAN FD / 主驾座椅 · CH0 与副驾 T2G 路径禁止"
: linkedHardwareReady
? "CH0 → 副驾座椅控制器 · CH1 → 车身 CAN FD"
: seatHardwareReady
? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
: "尚未配置可执行的物理 CAN 通道";
$("#hardwareBadge").className = `status-badge ${hardwareExecutionReady ? "" : "danger"}`.trim();
$("#hardwareBadge").innerHTML = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? 'CH1 车端已配置'
: linkedHardwareReady
? '双通道已配置'
: seatHardwareReady
? '座椅台架已配置'
: '硬件锁定';
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `CH1 主驾 + 车端设备 · 物理字段回读${accessText}${concurrencyText}`
: linkedHardwareReady
? `CH0 座椅 + CH${vehicleCan.channel} 车身${accessText}${concurrencyText}`
: seatHardwareReady
? `CH${hardware.channel} 座椅 · 物理反馈${accessText}${concurrencyText}`
: "已阻断";
$("#hardwareCoverage").className = hardwareExecutionReady ? "available" : "blocked";
const modeAvailable = state.executionMode === "hardware"
? hardwareExecutionReady
: state.executionMode === "simulated_hardware"
? simulation.enabled
: true;
selectExecutionMode(modeAvailable ? state.executionMode : "dry_run");
refreshIcons();
} catch (error) {
$("#modelBadge").textContent = "运行时离线";
$("#modelBadge").classList.add("danger");
$("#providerNote").textContent = "无法读取后端运行状态";
showToast(`运行时连接失败:${error.message}`);
}
}
async function synchronizePassengerSeatState() {
if (!state.runtime?.hardware?.configured) return false;
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
hardware_client_id: state.hardwareClientId,
});
try {
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
cache: "no-store",
});
if (response.status === 401) {
window.location.replace("/login");
return false;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
renderSeatState({ passenger: payload.actuator_state?.passenger || {} });
state.seatFeedbackSources.passenger = payload.complete
? "physical_can"
: "physical_partial";
state.seatFeedbackSource = state.seatFeedbackSources.passenger;
updateSeatFeedbackModeLabel();
return true;
} catch (_error) {
if (!state.seatFeedbackSources.passenger.startsWith("physical")) {
state.seatFeedbackSources.passenger = "unavailable";
updateSeatFeedbackModeLabel();
}
return false;
}
}
async function synchronizeDriverSeatState() {
if (!state.runtime?.vehicle_can?.configured) return false;
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
hardware_client_id: state.hardwareClientId,
});
try {
const response = await apiRequest(`/api/hardware/vehicle-state?${query}`, { cache: "no-store" });
if (response.status === 401) {
window.location.replace("/login");
return false;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
applyAuthoritativeVehicleState(payload);
state.seatFeedbackSources.driver = payload.complete ? "physical_can_ch1" : "physical_partial";
state.seatFeedbackSource = state.seatFeedbackSources.driver;
updateSeatFeedbackModeLabel();
return true;
} catch (_error) {
if (!state.seatFeedbackSources.driver.startsWith("physical")) {
state.seatFeedbackSources.driver = "unavailable";
updateSeatFeedbackModeLabel();
}
return false;
}
}
async function synchronizePhysicalSeatState() {
if (state.physicalStateSyncPromise) return state.physicalStateSyncPromise;
state.physicalStateSyncPromise = (async () => {
// Both channels share one adapter owner; keep passive reads serialized.
const passenger = await synchronizePassengerSeatState();
const driver = await synchronizeDriverSeatState();
return passenger || driver;
})();
try {
return await state.physicalStateSyncPromise;
} finally {
state.physicalStateSyncPromise = null;
}
}
async function loadAuthStatus() {
const response = await apiRequest("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
const auth = await response.json();
if (auth.enabled && !auth.authenticated) {
window.location.replace("/login");
return;
}
$("#logoutButton").hidden = !auth.enabled;
}
async function logout() {
await apiRequest("/api/auth/logout", { method: "POST" });
window.location.replace("/login");
}
function setRunStatus(status, text) {
const element = $("#runStatus");
element.className = `run-status ${status}`;
element.textContent = text;
}
function resetPipeline() {
$$(".pipeline-step").forEach((step) => step.classList.remove("active", "succeeded", "failed"));
}
function startProgress() {
resetPipeline();
const steps = $$(".pipeline-step");
let index = 0;
steps[0].classList.add("active");
state.progressTimer = window.setInterval(() => {
steps[index]?.classList.remove("active");
steps[index]?.classList.add("succeeded");
index = Math.min(index + 1, steps.length - 1);
steps[index]?.classList.add("active");
}, 360);
}
function renderPipeline(events) {
window.clearInterval(state.progressTimer);
resetPipeline();
const byStage = new Map(events.map((event) => [event.stage, event]));
const schemaEvent = byStage.get("schema_validate");
const domainEvent = byStage.get("domain_validate");
$$(".pipeline-step").forEach((step) => {
let event = byStage.get(step.dataset.stage);
if (step.dataset.stage === "domain_validate") {
event = schemaEvent?.status === "failed" ? schemaEvent : domainEvent;
}
if (!event) return;
step.classList.add(event.status === "failed" || event.status === "waiting_confirmation" ? "failed" : "succeeded");
});
}
function yamlScalar(value) {
if (value === null) return "null";
if (typeof value === "boolean" || typeof value === "number") return String(value);
return JSON.stringify(String(value));
}
function yamlLines(value, indent = 0) {
const pad = " ".repeat(indent);
if (Array.isArray(value)) {
if (!value.length) return [`${pad}[]`];
return value.flatMap((item) => {
if (item !== null && typeof item === "object") {
const lines = yamlLines(item, indent + 2);
return [`${pad}-${lines[0].slice(indent + 1)}`, ...lines.slice(1)];
}
return [`${pad}- ${yamlScalar(item)}`];
});
}
if (value !== null && typeof value === "object") {
const entries = Object.entries(value);
if (!entries.length) return [`${pad}{}`];
return entries.flatMap(([key, item]) => {
if (item !== null && typeof item === "object") {
return [`${pad}${key}:`, ...yamlLines(item, indent + 2)];
}
return [`${pad}${key}: ${yamlScalar(item)}`];
});
}
return [`${pad}${yamlScalar(value)}`];
}
function toYaml(value) {
return value ? `${yamlLines(value).join("\n")}\n` : "# 尚未生成\n";
}
function renderDecision(result) {
const list = $("#decisionList");
list.replaceChildren();
result.decision_summary.forEach((summary) => {
const item = document.createElement("li");
item.textContent = summary;
list.append(item);
});
$("#providerUsed").textContent = result.provider_used || "--";
$("#tracePanelState").textContent = result.provider_used || result.provider_requested || "READY";
}
function renderTrace(events) {
const list = $("#traceList");
list.replaceChildren();
let duration = 0;
events.forEach((event) => {
duration += event.duration_ms || 0;
const row = document.createElement("div");
row.className = `trace-event ${event.status}`;
const code = document.createElement("code");
code.textContent = event.stage;
const summary = document.createElement("span");
summary.textContent = event.summary;
const time = document.createElement("small");
time.textContent = `${event.duration_ms || 0} ms`;
row.append(code, summary, time);
list.append(row);
});
$("#traceDuration").textContent = `${duration} ms`;
$("#traceNavMeta").textContent = `${events.length} 个阶段 · ${duration} ms`;
}
function renderValidation(issues) {
const list = $("#validationList");
list.replaceChildren();
$("#validationCount").textContent = `${issues.length} 条`;
if (!issues.length) {
const row = document.createElement("div");
row.className = "validation-item";
row.innerHTML = "PASSall公共 Schema、领域 Schema 和语义约束均通过。";
list.append(row);
return;
}
issues.forEach((issue) => {
const row = document.createElement("div");
row.className = `validation-item ${issue.severity}`;
const severity = document.createElement("b");
severity.textContent = issue.severity.toUpperCase();
const code = document.createElement("code");
code.textContent = `${issue.code} · ${issue.path}`;
const message = document.createElement("span");
message.textContent = issue.message;
row.append(severity, code, message);
list.append(row);
});
}
function resetSeatState() {
$$(".thermal-states > span").forEach((item) => {
item.classList.remove("on");
$("b", item).textContent = "--";
});
$$(".motion-axis").forEach((item) => {
$("b", item).textContent = "--";
$("i", item).style.width = "0%";
item.classList.remove("fault");
});
}
function mergeSeatActuatorState(previousState, patchState) {
const mergedState = { ...(previousState || {}) };
Object.entries(patchState || {}).forEach(([seat, functions]) => {
const previousSeat = mergedState[seat] || {};
const mergedSeat = { ...previousSeat };
Object.entries(functions || {}).forEach(([feature, value]) => {
if (feature === "position") {
const previousPosition = previousSeat.position || {};
const mergedPosition = { ...previousPosition };
Object.entries(value || {}).forEach(([axis, motion]) => {
mergedPosition[axis] = motion && typeof motion === "object"
? { ...(previousPosition[axis] || {}), ...motion }
: motion;
});
mergedSeat.position = mergedPosition;
return;
}
if (feature === "dynamic_mode") {
const item = $(`.seat-card[data-seat="${seat}"] [data-dynamic-mode]`);
if (!item) return;
const running = value.state === "running";
item.classList.toggle("running", running);
const runtimeMinutes = Number(value.max_runtime_ms || 600000) / 60000;
const runtimeLabel = Number.isInteger(runtimeMinutes)
? String(runtimeMinutes)
: runtimeMinutes.toFixed(1);
$("b", item).textContent = running
? `${value.name} · 循环中 · 最长${runtimeLabel}分钟`
: "未运行";
return;
}
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
? { ...(previousSeat[feature] || {}), ...value }
: value;
});
mergedState[seat] = mergedSeat;
});
return mergedState;
}
function renderSeatState(actuatorState) {
if (!actuatorState) return;
const mergedActuatorState = mergeSeatActuatorState(
state.seatActuatorState,
actuatorState,
);
state.seatActuatorState = mergedActuatorState;
resetSeatState();
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
const displayFunctions = seat === "driver"
? twinDeviceForSeat("driver", { driver: functions })
: functions;
Object.entries(displayFunctions).forEach(([feature, value]) => {
if (feature === "position_feedback" && seat === "driver") {
const status = $('.seat-card[data-seat="driver"] [data-dynamic-mode] b');
const initialized = Object.values(value.initialized || {}).filter(Boolean).length;
if (status) status.textContent = value.absolute_position_available === false
? `0x494 初始化 ${initialized}/4 · 无绝对位置`
: "CH1 状态已同步";
return;
}
if (feature === "position") {
Object.entries(value).forEach(([axis, motion]) => {
const item = $(`.seat-card[data-seat="${seat}"] [data-axis="${axis}"]`);
if (!item) return;
const position = Number(motion.position_pct || 0);
const estimated = Boolean(
motion.position_estimated || motion.position_valid === false
);
$("b", item).textContent = `${estimated ? "≈" : ""}${position}%`;
item.title = estimated ? "Hall 已确认相对运动;绝对位置尚未标定" : "";
$("i", item).style.width = `${Math.max(0, Math.min(100, position))}%`;
item.classList.toggle("fault", Boolean(motion.fault));
});
return;
}
const item = $(`.seat-card[data-seat="${seat}"] [data-function="${feature}"]`);
if (!item) return;
if (feature === "massage") {
const mode = String(value.mode || "off");
const strength = String(value.strength || "off");
const active = !["off", "unknown", ""].includes(mode)
|| !["off", "unknown", ""].includes(strength);
item.classList.toggle("on", active);
$("b", item).textContent = active
? `${mode.toUpperCase()} · ${strength.toUpperCase()}`
: "OFF";
return;
}
item.classList.toggle("on", value.state === "on");
$("b", item).textContent = value.state === "on" ? `ON · L${value.level}` : "OFF";
});
});
updateSeatTwin(mergedActuatorState);
}
function renderDomainState(domainState) {
if (!domainState) return;
const outletModeLabel = (value) => {
const normalized = String(value || "").toLowerCase().replaceAll("-", "_");
if (["avoid_people", "avoid_person", "avoid_person_blow", "avoid_occupant"].includes(normalized)) {
return "避人吹";
}
if (["sweep", "sweeping", "oscillate", "oscillation"].includes(normalized)) {
return "扫风";
}
return "-";
};
const onOffLabel = (value) => {
if ([true, 1, "1", "on", "open", "opened", "active"].includes(value)) return "开启";
return "关闭";
};
const percentageValue = (value) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.min(100, numeric)) : 0;
};
const climate = domainState.climate;
if (climate) {
const active = climate.power === "on" || climate.ac === "on" || Number(climate.fan_level) > 0;
const driverTemperature = climate.zones?.driver?.temperature_c
?? climate.zones?.vehicle?.temperature_c
?? 24;
const passengerTemperature = climate.zones?.passenger?.temperature_c
?? climate.zones?.vehicle?.temperature_c
?? 24;
$('[data-domain-panel="climate"]').classList.toggle("active", active);
$("#climatePower").textContent = climate.power === "on" ? "ON" : (active ? "ACTIVE" : "OFF");
$("#driverTemperature").innerHTML = `${driverTemperature}℃`;
$("#passengerTemperature").innerHTML = `${passengerTemperature}℃`;
$("#driverAirDirection").textContent = outletModeLabel(climate.zones?.driver?.outlet_mode);
$("#passengerAirDirection").textContent = outletModeLabel(climate.zones?.passenger?.outlet_mode);
$("#airflowVisual").dataset.level = String(Math.max(0, Math.min(7, Number(climate.fan_level) || 0)));
$("#fanLevel").textContent = `${climate.fan_level ?? 0} 档`;
$("#cycleMode").textContent = climate.cycle_mode === "internal" ? "内循环" : "外循环";
$("#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(
state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
? driverTemperature
: passengerTemperature,
) || 24;
}
}
const roof = domainState.roof;
if (roof) {
const transmittance = Math.max(0, Math.min(100, Number(roof.transmittance_pct) || 0));
const shadePosition = Math.max(0, Math.min(100, Number(roof.shade_position_pct) || 0));
$('[data-domain-panel="roof"]').classList.toggle(
"active",
transmittance < 100 || shadePosition > 0 || roof.automatic_dimming === "on",
);
const roofGlass = $("#roofGlass");
const roofShade = $("#roofShade");
if (roofGlass) roofGlass.style.background = `rgba(105, 160, 185, ${0.12 + transmittance * 0.0042})`;
if (roofShade) roofShade.style.width = `${100 - shadePosition}%`;
$("#shadePositionValue").textContent = `${shadePosition}%`;
const shadePositionMeter = $("#shadePositionMeter");
if (shadePositionMeter) shadePositionMeter.style.width = `${shadePosition}%`;
const shadeMotionLabels = {
stopped: "停止",
opening: "打开中",
closing: "关闭中",
anti_pinch: "防夹",
unknown: "未知",
};
$("#shadeMotionState").textContent = shadeMotionLabels[roof.motion_state] || "未知";
$("#shadeInitializedState").textContent = roof.initialized === true
? "已初始化"
: roof.initialized === false ? "未初始化" : "未知";
$("#shadeThermalState").textContent = roof.thermal_protection === true ? "保护中" : "正常";
$("#roofState").textContent = roof.fault ? "FAULT" : "READY";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.roofTransmittance = transmittance;
vehicleSystems.targets.roofShadePosition = shadePosition;
}
}
const vehicleStatus = domainState.vehicle_status || domainState.vehicle_safety;
const door = domainState.door;
if (vehicleStatus || door) {
const panel = $('[data-domain-panel="vehicle_status"]');
const gear = String(vehicleStatus?.gear || "unknown").toUpperCase();
const speedValue = vehicleStatus?.speed_kmh;
const speed = Number(speedValue);
const hasGear = ["P", "R", "N", "D"].includes(gear);
const hasSpeed = speedValue !== null
&& speedValue !== undefined
&& speedValue !== ""
&& Number.isFinite(speed);
$("#vehicleGear").textContent = hasGear ? gear : "--";
$("#vehicleSpeed").textContent = hasSpeed
? `${speed < 10 ? speed.toFixed(1) : speed.toFixed(0)} km/h`
: "-- km/h";
const doorLabels = { lock: "已锁", unlock: "未锁", unknown: "未知" };
const doorIds = {
front_left: "frontLeftDoorState",
front_right: "frontRightDoorState",
rear_left: "rearLeftDoorState",
rear_right: "rearRightDoorState",
};
let knownDoorCount = 0;
Object.entries(doorIds).forEach(([name, id]) => {
const lockState = String(door?.locks?.[name]?.state || "unknown").toLowerCase();
if (lockState !== "unknown") knownDoorCount += 1;
$(`#${id}`).textContent = doorLabels[lockState] || "未知";
const row = panel?.querySelector(`[data-door-state="${name}"]`);
if (row) row.dataset.state = lockState;
});
const hasPhysicalState = vehicleStatus?.feedback_source === "physical_can_ch1"
|| door?.feedback_source === "physical_can_ch1";
panel?.classList.toggle("active", hasPhysicalState);
$("#vehicleTelemetryState").textContent = hasPhysicalState
? (hasGear && hasSpeed && knownDoorCount === 4 ? "实时" : "部分")
: "只读";
}
const ambient = domainState.ambient_light;
if (ambient) {
const active = ambient.power === "on";
const indexedPhysicalColor = ambient.feedback_source === "physical_can_ch1"
&& Number.isInteger(ambient.front_color_index);
const color = indexedPhysicalColor
? "#D6DCE0"
: /^#[0-9A-Fa-f]{6}$/.test(ambient.color_hex || "")
? ambient.color_hex.toUpperCase()
: "#74D9FF";
const physicalBrightness = Number(ambient.front_brightness_level);
const brightness = Math.max(0, Math.min(100,
Number.isFinite(physicalBrightness) && ambient.feedback_source === "physical_can_ch1"
? physicalBrightness * 20
: Number(ambient.brightness_pct) || 0,
));
const opacity = active ? Math.max(0.18, brightness / 100) : 0.08;
const modeLabels = {
camping: "露营",
rest: "小憩",
cinema: "观影",
meditation: "冥想",
romantic: "浪漫",
custom: "自定义",
on: "开启",
off: "关闭",
};
const lightPanel = $('[data-domain-panel="ambient_light"]');
lightPanel.dataset.ambientPower = ambient.power || "unknown";
$$(".ambient-line").forEach((line) => {
line.style.background = color;
line.style.boxShadow = `0 0 ${Math.round(5 + brightness / 8)}px ${color}`;
line.style.opacity = String(opacity);
});
$("#ambientMode").textContent = indexedPhysicalColor
? (active ? "开启(车端色号)" : "关闭")
: modeLabels[ambient.mode] || ambient.mode || "自定义";
$("#ambientBrightness").textContent = `${ambient.front_brightness_level ?? "-"} / ${ambient.rear_brightness_level ?? "-"}`;
$("#ambientColorIndex").textContent = `${ambient.front_color_index ?? "-"} / ${ambient.rear_color_index ?? "-"}`;
[
["ambientFrontMeter", ambient.front_brightness_level],
["ambientRearMeter", ambient.rear_brightness_level],
].forEach(([id, level]) => {
const meter = $(`#${id}`);
if (!meter) return;
const normalizedLevel = Math.max(0, Math.min(5, Number(level) || 0));
meter.style.background = color;
meter.style.opacity = String(active ? 0.28 + normalizedLevel * 0.144 : 0.18);
meter.style.boxShadow = active
? `0 0 ${3 + normalizedLevel * 1.5}px ${color}`
: "none";
});
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.ambientPower = active ? 1 : 0;
vehicleSystems.targets.ambientBrightness = brightness;
vehicleSystems.colors.ambientTarget.set(color);
}
}
const interiorLight = domainState.interior_light;
if (interiorLight) {
const lightPanel = $('[data-domain-panel="ambient_light"]');
lightPanel.dataset.interiorPower = interiorLight.power || "unknown";
$("#interiorLightPower").textContent = interiorLight.power === "on" ? "开启" : "关闭";
}
if (ambient || interiorLight) {
const lightPanel = $('[data-domain-panel="ambient_light"]');
const ambientOn = lightPanel.dataset.ambientPower === "on";
const interiorOn = lightPanel.dataset.interiorPower === "on";
lightPanel.classList.toggle("active", ambientOn || interiorOn);
$("#ambientPower").textContent = ambientOn
? "AMBIENT"
: interiorOn ? "INTERIOR" : "OFF";
}
const windowState = domainState.window || domainState.windows;
if (windowState) {
const readWindowPosition = (key) => {
const source = windowState.positions?.[key]
?? windowState.windows?.[key]
?? windowState[key];
return percentageValue(source?.position_pct ?? source?.opening_pct ?? source?.position ?? source);
};
const windowPositions = {
frontLeftWindow: readWindowPosition("front_left"),
frontRightWindow: readWindowPosition("front_right"),
rearLeftWindow: readWindowPosition("rear_left"),
rearRightWindow: readWindowPosition("rear_right"),
};
Object.entries(windowPositions).forEach(([id, value]) => {
$(`#${id}`).textContent = `${value}%`;
const windowKey = {
frontLeftWindow: "front_left",
frontRightWindow: "front_right",
rearLeftWindow: "rear_left",
rearRightWindow: "rear_right",
}[id];
const meter = $(`[data-window-meter="${windowKey}"]`);
if (meter) meter.style.setProperty("--window-open", `${value}%`);
});
const anyWindowOpen = Object.values(windowPositions).some((value) => value > 0);
$('[data-domain-panel="window"]').classList.toggle("active", anyWindowOpen);
$("#windowState").textContent = anyWindowOpen ? "OPEN" : "CLOSED";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
Object.entries(windowPositions).forEach(([id, value]) => {
const windowKey = {
frontLeftWindow: "front_left",
frontRightWindow: "front_right",
rearLeftWindow: "rear_left",
rearRightWindow: "rear_right",
}[id];
vehicleSystems.targets.windowPositions[windowKey] = value;
});
}
}
const frontLight = domainState.front_light
|| domainState.front_lights
|| domainState.exterior_light
|| domainState.headlight;
if (frontLight) {
const readLightState = (...keys) => {
const key = keys.find((candidate) => frontLight[candidate] !== undefined);
const source = key ? frontLight[key] : undefined;
return source?.state ?? source?.power ?? source;
};
const lightStates = {
lowBeamState: readLightState("low_beam", "dipped_beam"),
highBeamState: readLightState("high_beam", "main_beam"),
fogLightState: readLightState("fog_light", "fog"),
turnSignalState: readLightState("turn_signal", "indicator"),
};
Object.entries(lightStates).forEach(([id, value]) => {
$(`#${id}`).textContent = onOffLabel(value);
const meter = $(`[data-light-meter="${id}"]`);
if (meter) meter.dataset.state = onOffLabel(value) === "开启" ? "on" : "off";
});
const anyLightOn = Object.values(lightStates).some((value) => onOffLabel(value) === "开启");
$('[data-domain-panel="front_light"]').classList.toggle("active", anyLightOn);
$("#frontLightState").textContent = anyLightOn ? "ON" : "OFF";
const vehicleSystems = state.seatTwin?.vehicleSystems;
if (vehicleSystems) {
vehicleSystems.targets.lowBeam = onOffLabel(lightStates.lowBeamState) === "开启" ? 1 : 0;
vehicleSystems.targets.highBeam = onOffLabel(lightStates.highBeamState) === "开启" ? 1 : 0;
vehicleSystems.targets.fogLight = onOffLabel(lightStates.fogLightState) === "开启" ? 1 : 0;
vehicleSystems.targets.turnSignal = onOffLabel(lightStates.turnSignalState) === "开启" ? 1 : 0;
}
}
}
function renderedConfig(result) {
if (result.scene_config) {
return {
scene_config: result.scene_config,
function_configs: result.function_configs,
};
}
return result.preset_config || result.function_config;
}
function renderedPlan(result) {
const plans = result.command_plans || {};
return Object.keys(plans).length > 1 ? plans : result.command_plan;
}
function renderSavedPresets(result) {
const indicator = $("#presetIndicator");
const presets = result.saved_presets || [];
if (!presets.length) {
indicator.hidden = true;
return;
}
const latest = result.preset_config
? {
name: result.preset_config.metadata.name,
target: result.preset_config.metadata.labels.target,
}
: presets[presets.length - 1];
const targetName = latest.target === "driver" ? "主驾" : "副驾";
$("span", indicator).textContent = `已保存 ${targetName} · ${latest.name}`;
indicator.hidden = false;
}
const CONFIG_DOMAIN_META = {
seat: { label: "副驾座椅", icon: "armchair", className: "seat" },
climate: { label: "空调", icon: "fan", className: "climate" },
ambient_light: { label: "氛围灯", icon: "lamp-ceiling", className: "ambient" },
roof: { label: "智能天幕", icon: "panels-top-left", className: "roof" },
door: { label: "车门锁", icon: "door-open", className: "generic" },
window: { label: "车窗", icon: "panels-top-left", className: "generic" },
outlet: { label: "电动风口", icon: "wind", className: "climate" },
interior_light: { label: "车内灯", icon: "lightbulb", className: "ambient" },
};
const CONFIG_CAPABILITY_LABELS = {
"seat.set_ventilation": "设置座椅通风",
"seat.set_heating": "设置座椅加热",
"seat.set_longitudinal": "调节座椅前后位置",
"seat.set_longitudinal_position": "调节座椅前后位置",
"seat.set_cushion_tilt": "调节坐垫俯仰",
"seat.set_backrest_recline": "调节靠背角度",
"seat.set_legrest_height": "调节腿托角度",
"seat.set_legrest_extension": "调节腿托伸缩",
"seat.start_dynamic_mode": "启动动态座椅模式",
"seat.stop_dynamic_mode": "停止动态座椅模式",
"climate.set_temperature": "设置空调温度",
"climate.set_fan_level": "设置空调风量",
"climate.set_power": "设置空调电源",
"climate.set_ac": "设置 A/C",
"climate.set_cycle_mode": "设置内外循环",
"climate.set_outlet_mode": "设置出风模式",
"climate.set_front_defrost": "设置前风挡除霜",
"climate.set_control_mode": "设置空调控制模式",
"door.set_vehicle_lock": "整车门锁(当前 CH1 仅观测)",
"door.set_rear_lock": "二排门锁(当前 CH1 仅观测、已停用)",
"window.set_motion": "控制车窗动作",
"window.set_position": "设置车窗开度",
"outlet.set_position_percent": "设置电动风口位置",
"outlet.set_calibrated_position": "设置电动风口标定位置",
"interior_light.set_indoor_power": "设置室内灯",
"interior_light.set_wall_power": "设置壁灯",
"interior_light.set_reading_power": "设置阅读灯",
"ambient_light.set_power": "设置氛围灯电源",
"ambient_light.set_illumination": "设置氛围灯颜色与亮度",
"ambient_light.set_color": "设置氛围灯颜色",
"ambient_light.set_brightness": "设置氛围灯亮度",
"ambient_light.set_mode": "设置氛围灯模式",
"roof.set_transmittance": "设置天幕透光率",
"roof.set_shade_position": "设置遮阳帘位置",
"roof.set_shade_motion": "控制遮阳帘动作",
"roof.set_automatic_dimming": "设置自动调光",
};
const CONFIG_PARAMETER_LABELS = {
power: "电源",
state: "状态",
level: "档位",
temperature_c: "目标温度",
fan_level: "目标风量",
position_pct: "目标位置",
angle_deg: "目标角度",
color_hex: "目标颜色",
brightness_pct: "目标亮度",
transmittance_pct: "目标透光率",
shade_position_pct: "遮阳帘位置",
automatic_dimming: "自动调光",
cycle_mode: "循环模式",
outlet_mode: "出风模式",
mode: "工作模式",
mode_id: "固件模式编号",
};
function makeConfigElement(tagName, className, textValue) {
const element = document.createElement(tagName);
if (className) element.className = className;
if (textValue != null) element.textContent = textValue;
return element;
}
function configTargetLabel(target = {}, domain) {
const scopes = {
driver: "主驾",
passenger: "副驾",
front_left: "主驾",
front_right: "副驾",
front_row: "前排",
rear_row: "后排",
vehicle: "全车",
cabin: "全车舱",
};
const rawScope = target.instance || target.zone || (domain === "seat" ? "passenger" : "vehicle");
const scope = scopes[rawScope] || rawScope;
if (domain === "seat") return `${scope}座椅`;
const device = CONFIG_DOMAIN_META[domain]?.label || target.device || domain;
return `${scope} · ${device}`;
}
function formatConfigValue(name, value) {
const stateLabels = {
on: "开启",
off: "关闭",
internal: "内循环",
external: "外循环",
face: "面部出风",
feet: "脚部出风",
cinema: "观影",
rest: "小憩",
custom: "自定义",
};
if (typeof value === "string" && stateLabels[value]) return stateLabels[value];
if (name === "temperature_c") return `${value} ℃`;
if (name === "angle_deg") return `${value}°`;
if (name === "fan_level" || name === "level") return `${value} 档`;
if (name.endsWith("_pct")) return `${value}%`;
if (typeof value === "boolean") return value ? "开启" : "关闭";
if (value && typeof value === "object") return JSON.stringify(value);
return String(value ?? "--");
}
function configEntries(result) {
const configured = result.function_configs || {};
if (Object.keys(configured).length) return Object.entries(configured);
const single = result.function_config || result.preset_config;
const domain = single?.spec?.domain || "seat";
return single ? [[domain, single]] : [];
}
function planForDomain(result, domain) {
if (result.command_plans?.[domain]) return result.command_plans[domain];
return result.command_plan?.spec?.domain === domain ? result.command_plan : null;
}
function renderConfigCards(result) {
const container = $("#configCards");
container.replaceChildren();
const entries = configEntries(result);
const operations = entries.flatMap(([configKey, config]) => (
(config?.spec?.operations || []).map((operation) => ({
configKey,
domain: config?.spec?.domain || configKey,
config,
operation,
}))
));
if (!operations.length) {
const empty = makeConfigElement("div", "config-empty");
empty.innerHTML = '暂无可展示的功能配置原始配置仍可在下方展开查看。';
container.append(empty);
refreshIcons();
return 0;
}
const sceneSummary = makeConfigElement("div", "config-scene-summary");
const sceneCopy = makeConfigElement("div", "config-scene-copy");
const sceneIcon = makeConfigElement("span", "config-scene-icon");
sceneIcon.innerHTML = '';
const sceneText = makeConfigElement("div", "");
sceneText.append(
makeConfigElement("strong", "", result.scene_config?.metadata?.name || "单域功能配置"),
makeConfigElement("span", "", result.scene_config ? "SceneConfig 联合场景" : "FunctionConfig 独立控制"),
);
sceneCopy.append(sceneIcon, sceneText);
const sceneStats = makeConfigElement("div", "config-scene-stats");
[[entries.length, "控制域"], [operations.length, "控制动作"], [result.validation?.length || 0, "校验提示"]].forEach(([value, label]) => {
const stat = makeConfigElement("span", "");
stat.append(makeConfigElement("b", "", value), makeConfigElement("small", "", label));
sceneStats.append(stat);
});
sceneSummary.append(sceneCopy, sceneStats);
container.append(sceneSummary);
operations.forEach(({ configKey, domain, config, operation }, index) => {
const meta = CONFIG_DOMAIN_META[domain] || { label: domain, icon: "box", className: "generic" };
const card = makeConfigElement("article", `config-card config-${meta.className}`);
const header = makeConfigElement("header", "config-card-header");
const identity = makeConfigElement("div", "config-card-identity");
const icon = makeConfigElement("span", "config-card-icon");
icon.innerHTML = ``;
const title = makeConfigElement("div", "");
title.append(
makeConfigElement("small", "", `ACTION ${String(index + 1).padStart(2, "0")} · ${domain.toUpperCase()}`),
makeConfigElement("strong", "", CONFIG_CAPABILITY_LABELS[operation.capability] || operation.capability || meta.label),
);
identity.append(icon, title);
header.append(identity, makeConfigElement("span", "config-domain-badge", meta.label));
const objectBlock = makeConfigElement("section", "config-object-block");
objectBlock.append(
makeConfigElement("span", "config-block-label", "控制对象"),
makeConfigElement("strong", "config-object-name", configTargetLabel(operation.target, domain)),
makeConfigElement("code", "config-object-path", `${operation.target?.device || domain} / ${operation.target?.zone || operation.target?.instance || "vehicle"}`),
);
const parametersBlock = makeConfigElement("section", "config-parameters-block");
parametersBlock.append(makeConfigElement("span", "config-block-label", "目标参数"));
const parameterGrid = makeConfigElement("div", "config-parameter-grid");
const parameters = Object.entries(operation.parameters || {});
(parameters.length ? parameters : [["state", "--"]]).forEach(([name, value]) => {
const item = makeConfigElement("div", "config-parameter");
const valueNode = makeConfigElement("strong", "", formatConfigValue(name, value));
if (name === "color_hex" && /^#[0-9A-Fa-f]{6}$/.test(String(value))) {
const swatch = makeConfigElement("i", "config-color-swatch");
swatch.style.background = value;
valueNode.prepend(swatch);
}
item.append(
makeConfigElement("span", "", CONFIG_PARAMETER_LABELS[name] || name),
valueNode,
);
parameterGrid.append(item);
});
parametersBlock.append(parameterGrid);
const plan = planForDomain(result, configKey);
const allCommands = plan?.spec?.commands || [];
const matchedCommands = allCommands.filter((command) => !command.operation_ref || command.operation_ref === operation.id);
const command = matchedCommands[0] || null;
const hardwareBlock = makeConfigElement("section", `config-hardware-block ${command ? "mapped" : "unmapped"}`);
hardwareBlock.append(makeConfigElement("span", "config-block-label", "硬件映射"));
const route = makeConfigElement("div", "config-hardware-route");
const from = makeConfigElement("div", "config-route-node");
from.append(makeConfigElement("small", "", "逻辑配置"), makeConfigElement("strong", "", operation.id || "operation"));
const arrow = makeConfigElement("i", "config-route-arrow");
arrow.innerHTML = '';
const to = makeConfigElement("div", "config-route-node target");
to.append(
makeConfigElement("small", "", command?.bus || plan?.spec?.platform || "待编译"),
makeConfigElement("strong", "", command?.message || "暂无 CAN 命令"),
);
route.append(from, arrow, to);
hardwareBlock.append(route);
const signals = Object.keys(command?.signals || {});
const signalList = makeConfigElement("div", "config-signal-list");
if (signals.length) {
signals.slice(0, 5).forEach((signal) => signalList.append(makeConfigElement("code", "", signal)));
if (signals.length > 5) signalList.append(makeConfigElement("code", "config-signal-more", `+${signals.length - 5}`));
} else {
signalList.append(makeConfigElement("span", "config-unmapped-note", "虚拟状态可预览,等待硬件编译映射"));
}
hardwareBlock.append(signalList);
const footer = makeConfigElement("footer", "config-card-footer");
const executionScope = config.metadata?.labels?.execution_scope;
const executionScopeLabel = result.execution_mode === "hardware" && domain === "seat"
? "座椅 CAN · CH0"
: executionScope === "vehicle_can_channel_1"
? "车身 CAN · CH1"
: executionScope === "virtual_only" ? "仅虚拟执行" : "已通过配置校验";
footer.append(
makeConfigElement("span", "", `超时 ${operation.timeout_ms ?? "--"} ms`),
makeConfigElement("span", "", operation.failure_policy === "continue" ? "失败后继续" : "失败即中止"),
makeConfigElement("span", "", executionScopeLabel),
);
card.append(header, objectBlock, parametersBlock, hardwareBlock, footer);
container.append(card);
});
refreshIcons();
return operations.length;
}
function renderCode(result) {
const config = renderedConfig(result);
$("#configCode code").textContent = toYaml(config);
const configCardCount = renderConfigCards(result);
const configDomains = Object.keys(result.function_configs || {});
$("#configFileName").textContent = result.scene_config
? `scene.yaml + functions/{${configDomains.join(",")}}.yaml`
: result.preset_config
? "presets/seat.yaml"
: `functions/${configDomains[0] || "seat"}.yaml`;
$("#configNavMeta").textContent = `${configCardCount} 个控制动作`;
$("#planCode code").textContent = toYaml(renderedPlan(result));
const planDomains = Object.keys(result.command_plans || {});
$("#planFileName").textContent = planDomains.length > 1
? `command-plans/{${planDomains.join(",")}}.yaml`
: `command-plans/${planDomains[0] || "seat"}.yaml`;
const resultItems = result.execution_report?.spec?.results || [];
const telemetry = resultItems.find((item) => item.telemetry?.feedback_frame)?.telemetry
|| resultItems[0]?.telemetry;
const frames = resultItems
.map((item) => item.telemetry?.command_frame)
.filter(Boolean);
const summary = $("#frameSummary");
if (frames.length) {
const feedback = telemetry?.feedback_frame
? `${telemetry.feedback_frame.frame_id} · ${telemetry.feedback_frame.data_hex}`
: "NO DIRECT FEEDBACK";
const simulatedHil = result.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
const mode = simulatedHil
? "HIL · SIMULATED"
: result.execution_report?.spec?.mode === "hil" ? "HIL · PHYSICAL" : "DRY-RUN";
summary.innerHTML = `Frames${frames.length}First payload${frames[0].frame_id} · ${frames[0].data_hex}Feedback${feedback}Mode${mode}`;
$("#planNavMeta").textContent = `${frames.length} 帧 · ${mode}`;
} else if (telemetry?.operation === "save_posture_preset") {
summary.innerHTML = `OperationPRESET STORECANNO FRAME EMITTEDScopeCONVERSATION`;
$("#planNavMeta").textContent = "预设存储 · 无 CAN 帧";
} else {
summary.innerHTML = "Frame ID--Payload--ModeNOT EXECUTED";
$("#planNavMeta").textContent = "未生成 CAN 帧";
}
}
function renderExecution(result) {
const summary = $("#executionSummary");
const list = $("#resultList");
list.replaceChildren();
const report = result.execution_report;
if (!report) {
summary.className = "execution-summary failed";
const compileIssue = result.validation?.find((issue) => issue.code === "CAN_COMPILE_REJECTED");
const blockedReason = {
needs_confirmation: "请求需要补充确认",
capability_unavailable: "目标能力已识别,但对应域尚未接入执行器",
compile_failed: compileIssue?.message || "CAN 命令编译被安全约束阻止",
}[result.status] || "配置或语义校验未通过";
summary.innerHTML = `执行未开始${blockedReason}
`;
$("#resultNavMeta").textContent = "未执行 · 查看原因";
$("#resultPanelState").textContent = "BLOCKED";
$("#resultPanelState").className = "workspace-panel-state failed";
refreshIcons();
return;
}
const reportSucceeded = report.spec.status === "succeeded";
const partiallySucceeded = result.status === "partially_succeeded";
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
const presetSave = Boolean(result.preset_config);
const modeSave = Boolean(result.mode_profile);
const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware";
const physical = report.spec.mode === "hil" && !simulatedHil;
const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))];
const motionCount = report.spec.results.filter((item) => item.telemetry?.motion_axis).length;
const firmwareResult = report.spec.results.find((item) => item.telemetry?.firmware_verified);
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
const failedOperations = operationResults.filter((item) => item.status === "failed");
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
let executionDetail = modeSave
? `长期模式 ${result.mode_profile.name} 已写入配置库,未生成 CAN 命令`
: presetSave
? "当前五轴姿态已持久化到会话预设仓库,未生成 CAN 命令"
: simulatedHil
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only"
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 共 ${report.spec.results.length} 个操作已完成 CAN 编码与虚拟执行`;
if (partiallySucceeded) {
executionDetail = `部分执行完成:${succeededDomains.join(" / ")} 成功 ${succeededOperations.length} 项;${failedDomains.join(" / ")} 未完成 ${failedOperations.length} 项,失败设备未阻断其他设备`;
}
if (motionCount) executionDetail += `,含 ${motionCount} 个座椅位置电机反馈`;
if (firmwareResult?.telemetry?.programmed_this_request) executionDetail += ";本次已自动烧录并校验统一固件";
else if (firmwareResult) executionDetail += ";板上统一固件协议签名已校验";
if (result.deferred_requests?.length) {
executionDetail += ";未接入域请求已保留为 deferred request";
}
if (!reportSucceeded && !partiallySucceeded) {
const failure = report.spec.results.find((item) => item.status === "failed");
const failureTelemetry = failure?.telemetry || {};
executionDetail = simulatedHil
? `模拟故障 ${report.metadata?.labels?.fault_scenario || "unknown"}:${failure?.message || "故障已注入"};物理 CAN 未打开。`
: failureTelemetry.error_type === "CanGatewayError"
? `CAN 适配器或总线访问失败:${failure?.message || "无法打开或访问 CAN 适配器。"}`
: failureTelemetry.error_type === "FirmwareProgrammingError"
? `固件协议签名验证或自动烧录失败,座椅控制帧未发送:${failure?.message || "请检查 CMSIS-DAP 探针和 IAR 配置。"}`
: failureTelemetry.calibration_required
? `大幅相对移动已转换为绝对目标,但 Motor${failureTelemetry.motor_selector} 的 Position_Valid=0;未发送绝对移动命令,请先在台架清空并备好急停后执行显式端点标定。`
: failureTelemetry.failure_category === "stall_no_hall_activity"
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},未检测到持续 Hall 活动;可能已到目标方向机械端点/存在阻挡,或 Hall 供电、信号线未接通。请先安全地反向移动 2° 诊断。`
: failureTelemetry.failure_category === "stall_after_hall_activity"
? `Motor${failureTelemetry.motor_selector} STALL:Hall ${failureTelemetry.hall_before ?? "?"}→${failureTelemetry.hall_after ?? "?"},运动后发生堵转,请检查目标方向端点或机械阻挡。`
: failureTelemetry.motor_result_name
? `Motor${failureTelemetry.motor_selector} ${failureTelemetry.motor_result_name},板端事件 ${failureTelemetry.last_motor_event_name || "UNAVAILABLE"};${failureTelemetry.stop_all_attempted ? "已尝试发送 STOP_ALL。" : "请检查硬件回读。"}`
: failureTelemetry.stop_all_attempted
? "真实台架未返回满足目标的反馈,移动链路已尝试发送 STOP_ALL。"
: failure?.message || "真实台架未返回满足目标的物理反馈。";
}
const executionTitle = reportSucceeded
? modeSave ? "长期模式已保存"
: presetSave ? "姿态预设已保存"
: simulatedHil ? "模拟台架执行成功"
: physical ? "真实台架执行成功" : "虚拟执行成功"
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
const executionIcon = reportSucceeded
? ((presetSave || modeSave) ? "bookmark-check" : "circle-check-big")
: partiallySucceeded ? "circle-alert" : "circle-x";
summary.innerHTML = `${executionTitle}${executionDetail}
`;
$("#resultNavMeta").textContent = reportSucceeded
? `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
: "执行失败 · 查看回读";
$("#resultPanelState").textContent = reportSucceeded ? "SUCCEEDED" : partiallySucceeded ? "PARTIAL" : "FAILED";
$("#resultPanelState").className = `workspace-panel-state ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
report.spec.results.forEach((item) => {
const row = document.createElement("div");
row.className = "result-item";
row.innerHTML = ``;
const text = document.createElement("div");
const title = document.createElement("strong");
title.textContent = item.ref;
const detail = document.createElement("span");
detail.textContent = item.message;
text.append(title, detail);
const frame = document.createElement("code");
const firmwareTelemetry = item.telemetry?.firmware_verified
? [
item.telemetry.programmed_this_request ? "FLASHED" : "VERIFIED",
item.telemetry.firmware_revision == null ? null : `REV ${item.telemetry.firmware_revision}`,
item.telemetry.identity_frame_id && item.telemetry.identity_data_hex
? `${item.telemetry.identity_frame_id} · ${item.telemetry.identity_data_hex}`
: item.telemetry.protocol_signature,
].filter(Boolean).join(" · ")
: null;
frame.textContent = item.telemetry?.command_frame
? `${item.telemetry.command_frame.frame_id} ${item.telemetry.command_frame.data_hex}`
: firmwareTelemetry
? firmwareTelemetry
: "CONTROL PLANE · NO CAN";
row.append(text, frame);
list.append(row);
});
refreshIcons();
}
function physicalCh1StatePatch(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
if (value.source === "physical_can_ch1" || value.feedback_source === "physical_can_ch1") {
return value;
}
const patch = {};
Object.entries(value).forEach(([key, item]) => {
const child = physicalCh1StatePatch(item);
if (child) patch[key] = child;
});
return Object.keys(patch).length ? patch : null;
}
function physicalSeatStatePatch(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const source = value.source || value.feedback_source;
if (["physical_can", "physical_can_ch0", "physical_partial"].includes(source)) {
return value;
}
const patch = {};
Object.entries(value).forEach(([key, item]) => {
const child = physicalSeatStatePatch(item);
if (child) patch[key] = child;
});
return Object.keys(patch).length ? patch : null;
}
function isAuthoritativeVehicleStatePayload(payload) {
if (!payload || typeof payload !== "object") return false;
if (payload.conversation_id !== state.conversationId) return false;
return Boolean(
physicalCh1StatePatch(payload.actuator_state)
|| physicalCh1StatePatch(payload.domain_state),
);
}
function applyAuthoritativeVehicleState(payload) {
if (!isAuthoritativeVehicleStatePayload(payload)) return false;
const turn = Number(payload.conversation_turn);
if (Number.isFinite(turn) && turn < state.vehicleStateTurn) return false;
if (Number.isFinite(turn)) state.vehicleStateTurn = turn;
const actuatorPatch = physicalCh1StatePatch(payload.actuator_state);
const domainPatch = physicalCh1StatePatch(payload.domain_state);
const previousActuatorState = state.vehicleStateSnapshot?.actuatorState || null;
state.vehicleStateSnapshot = {
conversationId: payload.conversation_id,
conversationTurn: Number.isFinite(turn) ? turn : null,
requestId: payload.request_id || null,
actuatorState: actuatorPatch
? mergeSeatActuatorState(previousActuatorState, actuatorPatch)
: previousActuatorState,
domainState: domainPatch,
};
if (actuatorPatch?.driver) {
state.seatFeedbackSource = "physical_can_ch1";
state.seatFeedbackSources.driver = "physical_can_ch1";
}
renderSeatState(actuatorPatch);
renderDomainState(domainPatch);
updateSeatFeedbackModeLabel();
return true;
}
window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => {
const payload = event.detail;
if (!payload || payload.conversation_id !== state.conversationId) return;
const appliedCh1 = applyAuthoritativeVehicleState(payload);
const passengerPatch = physicalSeatStatePatch(payload.actuator_state?.passenger);
if (passengerPatch) {
renderSeatState({ passenger: passengerPatch });
state.seatFeedbackSource = "physical_can";
state.seatFeedbackSources.passenger = "physical_can";
updateSeatFeedbackModeLabel();
}
if (!appliedCh1 && !passengerPatch && payload.execution_mode !== "hardware") {
renderSeatState(payload.actuator_state);
renderDomainState(payload.domain_state);
if (payload.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
if (payload.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
state.seatFeedbackSource = "simulated_hardware";
updateSeatFeedbackModeLabel();
}
});
function renderResult(result) {
state.result = result;
const reportMode = result.execution_report?.spec?.mode;
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
if (feedbackSource === "simulated_hardware") {
state.seatFeedbackSource = "simulated_hardware";
if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated_hardware";
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated_hardware";
} else if (feedbackSource === "physical_can_ch1"
&& isAuthoritativeVehicleStatePayload(result)) {
state.seatFeedbackSource = "physical_can_ch1";
state.seatFeedbackSources.driver = "physical_can_ch1";
} else if (reportMode === "hil") {
state.seatFeedbackSource = result.execution_report?.spec?.physical_state?.complete
? "physical_can"
: "physical_partial";
state.seatFeedbackSources.passenger = state.seatFeedbackSource;
} else if (result.function_configs?.seat) {
state.seatFeedbackSource = "simulated";
if (result.actuator_state?.passenger) state.seatFeedbackSources.passenger = "simulated";
if (result.actuator_state?.driver) state.seatFeedbackSources.driver = "simulated";
}
const partiallySucceeded = result.status === "partially_succeeded";
const executionSucceeded = result.status === "succeeded" || partiallySucceeded;
const domains = Object.keys(result.function_configs || {});
const domainLabels = {
seat: "座椅",
climate: "空调",
roof: "天幕",
ambient_light: "氛围灯",
};
$("#resultTitle").textContent = partiallySucceeded
? `${domains.length} 域场景部分完成`
: executionSucceeded
? result.mode_profile
? "长期模式配置已保存"
: result.preset_config
? "座椅姿态预设已保存"
: result.scene_config
? `${domains.length} 域场景闭环完成`
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
: "请求未执行";
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
$("#requestMeta").textContent = `${result.request_id} / context:${conversationLabel} / turn:${result.conversation_turn} / ${result.provider_used || result.provider_requested}`;
$("#requestMeta").textContent += ` / ${result.execution_report?.spec?.mode || result.execution_mode || "dry_run"}`;
if (result.reasoning_effort_used) {
$("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`;
}
setRunStatus(
partiallySucceeded ? "partial" : executionSucceeded ? "succeeded" : "failed",
result.status.toUpperCase(),
);
renderPipeline(result.trace_events || []);
renderDecision(result);
renderTrace(result.trace_events || []);
renderValidation(result.validation || []);
const ch1HardwareResult = reportMode === "hil"
&& state.runtime?.vehicle_can?.target_policy === "ch1_body_can_driver_only";
if (ch1HardwareResult) {
applyAuthoritativeVehicleState(result);
} else {
renderSeatState(result.actuator_state);
renderDomainState(result.domain_state);
updateSeatFeedbackModeLabel();
}
updateTwinExplicitAngles(result);
renderSavedPresets(result);
if (result.mode_profile) loadRuntime();
renderCode(result);
renderExecution(result);
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
}
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
const submittedModeProfileId = state.pendingModeProfileId;
state.pendingModeProfileId = null;
if (!utterance) {
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
showToast("请输入车舱控制请求");
return;
}
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
if (state.executionMode === "hardware"
&& !driverOnlyCh1
&& !isLocalDecisionHost()
&& state.hardwareAccess?.state !== "approved") {
const accessMessage = state.hardwareAccess?.state === "pending"
? "申请仍在等待本地审批"
: "请先申请并获得本地真实台架使用权";
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
showToast(accessMessage);
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
detail: { utterance, result: null, error: new Error(accessMessage) },
}));
return;
}
const button = $("#runButton");
let completedResult = null;
let completedError = null;
button.disabled = true;
setModeChipsDisabled(true);
button.classList.add("loading");
button.innerHTML = '处理中';
refreshIcons();
setRunStatus("processing", "PROCESSING");
$("#resultTitle").textContent = "正在执行车舱编排";
startProgress();
try {
const response = await apiRequest("/api/requests", {
method: "POST",
timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
utterance,
provider: state.provider,
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
...(state.executionMode === "hardware" && !driverOnlyCh1
? { hardware_client_id: state.hardwareClientId }
: {}),
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
}),
});
const payload = await response.json();
if (response.status === 401) {
window.location.replace("/login");
return;
}
if (!response.ok) {
const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
requestError.code = payload.error;
requestError.retryAfter = Number(
payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
);
throw requestError;
}
renderResult(payload);
completedResult = payload;
if (state.executionMode === "hardware") {
await synchronizePhysicalSeatState();
}
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
if (completed) state.lastSuccessfulUtterance = utterance;
if (submittedModeProfileId) {
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
}
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
showToast(completed
? (payload.mode_profile
? `${payload.mode_profile.name}已创建并添加到功能模式`
: payload.status === "partially_succeeded"
? "已执行可用设备;部分设备未完成,详见执行结果"
: simulated
? "模拟台架执行完成"
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
: simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
} catch (error) {
completedError = error;
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
window.clearInterval(state.progressTimer);
resetPipeline();
if (error.code === "hardware_access_required") {
setRunStatus("failed", "ACCESS REQUIRED");
$("#resultTitle").textContent = "真实台架使用权无效";
await loadHardwareAccessStatus({ silent: true });
showToast(error.message);
} else if (error.code === "hardware_busy") {
setRunStatus("failed", "BENCH BUSY");
$("#resultTitle").textContent = "真实台架正在使用";
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
showToast(`${error.message}${retryHint}`);
} else {
setRunStatus("failed", "FAILED");
$("#resultTitle").textContent = "请求处理失败";
showToast(error.message);
}
} finally {
button.disabled = false;
setModeChipsDisabled(false);
button.classList.remove("loading");
const buttonLabel = state.executionMode === "hardware"
? "真实执行"
: state.executionMode === "simulated_hardware" ? "运行故障仿真" : "生成并执行";
button.innerHTML = `${buttonLabel}`;
refreshIcons();
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
detail: { utterance, result: completedResult, error: completedError },
}));
}
}
function activateTab(tabName) {
$$(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === tabName));
const panel = $(`#panel-${tabName}`);
if (!panel) return;
panel.classList.add("navigation-focus");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => panel.classList.remove("navigation-focus"), 900);
}
function initResultWorkspace() {
const panels = $$(".result-workspace .tab-panel");
panels.forEach((panel) => {
panel.hidden = false;
panel.classList.add("active");
});
if (!("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (!visible) return;
const tabName = visible.target.id.replace("panel-", "");
$$(".result-nav .tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.tab === tabName);
});
}, { rootMargin: "-18% 0px -56% 0px", threshold: [0.08, 0.3, 0.6] });
panels.forEach((panel) => observer.observe(panel));
}
async function copyArtifact(type) {
const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {});
if (!value) return showToast("当前没有可复制的内容");
await navigator.clipboard.writeText(toYaml(value));
showToast("已复制到剪贴板");
}
function downloadArtifact(type) {
const value = type === "config" ? renderedConfig(state.result || {}) : renderedPlan(state.result || {});
if (!value) return showToast("当前没有可下载的内容");
const blob = new Blob([toYaml(value)], { type: "text/yaml;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = type === "config"
? (state.result?.preset_config
? "seat-preset.yaml"
: state.result?.scene_config
? "cabin-scene-bundle.yaml"
: "function-config.yaml")
: "command-plans.yaml";
link.click();
URL.revokeObjectURL(link.href);
}
function startNewConversation() {
state.conversationId = createConversationId();
sessionStorage.setItem("cognitive-agent.conversation-id", state.conversationId);
window.location.reload();
}
window.CognitiveAgentConsole = {
executeUtterance(utterance, { autoSubmit = true } = {}) {
const input = $("#utterance");
const button = $("#runButton");
if (!input || !button || button.disabled) return false;
input.value = String(utterance || "").trim();
input.dispatchEvent(new Event("input", { bubbles: true }));
input.focus({ preventScroll: true });
if (autoSubmit) $("#requestForm").requestSubmit();
return true;
},
getExecutionMode() {
return state.executionMode;
},
getConversationId() {
return state.conversationId;
},
getResult() {
return state.result;
},
notify(message) {
showToast(String(message));
},
};
window.addEventListener("cognitive-agent:connection", (event) => {
const previousStatus = state.connectionStatus;
state.connectionStatus = event.detail?.status || "offline";
if (["offline", "degraded"].includes(previousStatus)
&& state.connectionStatus === "online") {
Promise.all([loadAuthStatus(), loadRuntime()])
.then(() => showToast("服务连接已恢复"))
.catch(() => {});
}
});
document.addEventListener("DOMContentLoaded", async () => {
initJointTwinLayout();
initResultWorkspace();
refreshIcons();
initSeatTwin();
$("#requestForm").addEventListener("submit", submitRequest);
$("#utterance").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
$("#requestForm").requestSubmit();
}
});
$("#newConversation").addEventListener("click", startNewConversation);
$("#logoutButton").addEventListener("click", logout);
$("#vehicleSelect").addEventListener("change", (event) => {
selectVehicle(event.target.value);
});
$(".mode-chips").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button[data-mode-command]");
if (button) applyModeCommand(
button.dataset.modeCommand,
button.dataset.modeProfileId || null,
);
});
$(".mode-chips").addEventListener(
"scroll",
() => window.requestAnimationFrame(updateModeRailNavigation),
{ passive: true },
);
$("#modeRailPrev").addEventListener("click", () => scrollModeRail(-1));
$("#modeRailNext").addEventListener("click", () => scrollModeRail(1));
$("#modeGalleryToggle").addEventListener("click", openModeGallery);
$("#modeGalleryClose").addEventListener("click", () => closeModeGallery());
$("#modeGalleryBackdrop").addEventListener("click", (event) => {
if (event.target === event.currentTarget) closeModeGallery();
});
$("#modeGalleryGroups").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const deleteButton = target?.closest("button[data-mode-delete]");
if (deleteButton) {
event.preventDefault();
event.stopPropagation();
deleteUserModeProfile(deleteButton.dataset.modeDelete);
return;
}
const button = target?.closest("button[data-mode-command]");
if (button) applyModeCommand(
button.dataset.modeCommand,
button.dataset.modeProfileId || null,
);
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !$("#modeGalleryBackdrop").hidden) {
event.preventDefault();
closeModeGallery();
}
});
window.addEventListener("resize", () => window.requestAnimationFrame(updateModeRailNavigation));
$("#utterance").addEventListener("input", () => {
if (!$("#saveModeComposer").hidden) updateModeDraftPreview();
});
$("#saveModeToggle").addEventListener("click", toggleSaveModeComposer);
$("#saveModeCancel").addEventListener("click", closeSaveModeComposer);
$("#saveModeConfirm").addEventListener("click", saveCurrentModeProfile);
$("#saveModeName").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault();
saveCurrentModeProfile();
}
});
$$(".segment[data-provider]").forEach((button) => button.addEventListener("click", () => selectProvider(button.dataset.provider)));
$$('[data-reasoning-effort]').forEach((button) => button.addEventListener("click", () => selectReasoningEffort(button.dataset.reasoningEffort)));
$$('[data-execution-mode]').forEach((button) => button.addEventListener("click", () => selectExecutionMode(button.dataset.executionMode)));
$$(".example-command").forEach((button) => button.addEventListener("click", () => {
$("#utterance").value = button.dataset.example;
}));
$$(".tab").forEach((tab) => tab.addEventListener("click", () => activateTab(tab.dataset.tab)));
$$('[data-copy]').forEach((button) => button.addEventListener("click", () => copyArtifact(button.dataset.copy)));
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
await loadAuthStatus();
await loadRuntime();
await pollModeExecutionEvents({ initial: true });
state.modeEventPoller = window.setInterval(pollModeExecutionEvents, 1000);
if (!isDriverOnlyCh1Runtime()) {
await loadHardwareAccessStatus({ silent: true });
state.hardwareAccessPoller = window.setInterval(
() => loadHardwareAccessStatus({ silent: true }),
3000,
);
}
await synchronizePhysicalSeatState();
state.physicalStatePoller = window.setInterval(() => {
const activeModule = document.body.dataset.module;
if (document.visibilityState !== "visible") return;
if (!["self-orchestration", "self-extension"].includes(activeModule)) return;
synchronizePhysicalSeatState();
}, 5000);
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
}
});