This commit is contained in:
vee
2026-07-30 18:55:28 +08:00
parent 49f5c49c53
commit 8cda026ac2
22 changed files with 315 additions and 86 deletions
+205 -28
View File
@@ -19,6 +19,8 @@ const state = {
result: null,
progressTimer: null,
seatTwin: null,
seatActuatorState: {},
availableSeatAxes: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -1629,36 +1631,83 @@ function classifyReferenceMaterial(material) {
}
const TWIN_OUTLINE_STYLE = {
body: { color: 0x315d69, lineOpacity: 0.52, fillOpacity: 0.014, renderOrder: 30 },
glass: { color: 0x78a7b2, lineOpacity: 0.18, fillOpacity: 0.006, renderOrder: 31 },
detail: { color: 0x536f76, lineOpacity: 0.28, fillOpacity: 0.009, renderOrder: 32 },
wheel: { color: 0x203d45, lineOpacity: 0.68, fillOpacity: 0.02, renderOrder: 33 },
interior: { color: 0x607e83, lineOpacity: 0.24, fillOpacity: 0.008, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0.28, fillOpacity: 0.009, renderOrder: 32 },
body: {
color: 0x315d69,
lineOpacity: 0.36,
occludedColor: 0xa7b8bb,
occludedLineOpacity: 0.12,
fillOpacity: 0.11,
thresholdAngle: 58,
renderOrder: 30,
},
glass: { color: 0x78a7b2, lineOpacity: 0, fillOpacity: 0.045, renderOrder: 31 },
detail: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.028, renderOrder: 32 },
wheel: {
color: 0x203d45,
lineOpacity: 0.52,
occludedColor: 0x9eafb2,
occludedLineOpacity: 0.14,
fillOpacity: 0.18,
thresholdAngle: 50,
renderOrder: 33,
},
interior: { color: 0x607e83, lineOpacity: 0, fillOpacity: 0.012, renderOrder: 36 },
light: { color: 0x536f76, lineOpacity: 0, fillOpacity: 0.07, renderOrder: 32 },
};
function addTwinMeshOutline(THREE, mesh, style, thresholdAngle = 30) {
if (!mesh.geometry?.attributes?.position || mesh.userData.twinOutline) return null;
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 material = new THREE.LineBasicMaterial({
const visibleMaterial = new THREE.LineBasicMaterial({
color: style.color,
transparent: true,
opacity: style.lineOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.LessEqualDepth,
toneMapped: false,
});
const outline = new THREE.LineSegments(geometry, material);
outline.name = `${mesh.name || "mesh"}_outline`;
outline.renderOrder = style.renderOrder;
outline.frustumCulled = mesh.frustumCulled;
mesh.add(outline);
mesh.userData.twinOutline = outline;
return outline;
visibleMaterial.onBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
"#include <project_vertex>",
"#include <project_vertex>\ngl_Position.z -= 0.0006 * gl_Position.w;",
);
};
visibleMaterial.customProgramCacheKey = () => "twin-visible-line-depth-bias-v1";
const visibleOutline = new THREE.LineSegments(geometry, visibleMaterial);
visibleOutline.name = `${mesh.name || "mesh"}_outline_visible`;
visibleOutline.renderOrder = style.renderOrder + 1;
visibleOutline.frustumCulled = mesh.frustumCulled;
mesh.add(visibleOutline);
mesh.userData.twinOutline = visibleOutline;
const occludedOpacity = style.occludedLineOpacity ?? 0;
if (occludedOpacity > 0) {
const occludedMaterial = new THREE.LineBasicMaterial({
color: style.occludedColor ?? style.color,
transparent: true,
opacity: occludedOpacity,
depthWrite: false,
depthTest: true,
depthFunc: THREE.GreaterDepth,
toneMapped: false,
});
const occludedOutline = new THREE.LineSegments(geometry, occludedMaterial);
occludedOutline.name = `${mesh.name || "mesh"}_outline_occluded`;
occludedOutline.renderOrder = style.renderOrder;
occludedOutline.frustumCulled = mesh.frustumCulled;
mesh.add(occludedOutline);
mesh.userData.twinOccludedOutline = occludedOutline;
}
return visibleOutline;
}
function applyTwinOutlineStyle(THREE, root, options = {}) {
@@ -1694,6 +1743,9 @@ function configureTwinSolidEntity(THREE, roots, options = {}) {
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);
@@ -1792,12 +1844,20 @@ function configureReferenceVehicleModel(THREE, vehicle) {
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);
if (material.map) {
material.map.colorSpace = THREE.SRGBColorSpace;
material.map.anisotropy = 8;
}
[
"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;
@@ -1832,11 +1892,15 @@ function configureReferenceVehicleModel(THREE, vehicle) {
} else if (/chrome|metal|alloy|rim/.test(descriptor) && material.color) {
material.color.setHex(0xaeb9bb);
}
const outlineStyle = TWIN_OUTLINE_STYLE[category] || TWIN_OUTLINE_STYLE.detail;
material.transparent = true;
material.opacity = outlineStyle.fillOpacity;
material.depthWrite = false;
material.side = THREE.DoubleSide;
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;
@@ -1851,7 +1915,12 @@ function configureReferenceVehicleModel(THREE, vehicle) {
const outlineCategory = categoryPriority.find((category) => categories.includes(category)) || "detail";
const outlineStyle = TWIN_OUTLINE_STYLE[outlineCategory];
object.renderOrder = outlineStyle.renderOrder - 1;
addTwinMeshOutline(THREE, object, outlineStyle, 30);
addTwinMeshOutline(
THREE,
object,
outlineStyle,
outlineStyle.thresholdAngle ?? 60,
);
});
return report;
}
@@ -2048,6 +2117,7 @@ function initSeatTwin() {
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;
@@ -2058,8 +2128,11 @@ function initSeatTwin() {
scene.background = new THREE.Color(0xe8eeec);
scene.fog = new THREE.Fog(0xe8eeec, 12, 19);
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
scene.add(new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2));
const 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);
@@ -2069,6 +2142,7 @@ function initSeatTwin() {
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);
@@ -2148,6 +2222,14 @@ function initSeatTwin() {
});
scene.add(fixedModels.driver.root);
vehicleSystems.root.add(fixedModels.rear);
[
vehicleSystems.root,
models.passenger.root,
fixedModels.driver.root,
fixedModels.rear,
].forEach((root) => {
root.traverse((object) => object.layers.set(1));
});
const initialDevice = () => ({
ventilation: { state: "off", level: 0 },
@@ -2300,6 +2382,7 @@ function initSeatTwin() {
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 },
@@ -2388,7 +2471,16 @@ function initSeatTwin() {
);
camera.lookAt(cameraTargetX, 1.14, -0.08);
positionTwinCallouts();
renderer.clear();
camera.layers.set(0);
renderer.render(scene, camera);
renderer.clearDepth();
const sceneBackground = scene.background;
scene.background = null;
camera.layers.set(1);
renderer.render(scene, camera);
scene.background = sceneBackground;
camera.layers.set(0);
if (!canvas.dataset.rendered) canvas.dataset.rendered = "true";
window.requestAnimationFrame(animate);
}
@@ -2461,6 +2553,58 @@ function applyTwinAngleRanges(vehicle) {
});
}
function applySeatMotorAvailability(vehicle) {
const motors = vehicle?.seat?.motors || {};
const availableAxes = new Set(Object.keys(motors));
state.availableSeatAxes = availableAxes;
Object.keys(TWIN_DEFAULT_POSITIONS).forEach((axis) => {
const available = availableAxes.has(axis);
$$(`[data-axis="${axis}"], [data-twin-axis="${axis}"], [data-callout="${axis}"]`)
.forEach((item) => {
item.classList.toggle("seat-axis-unavailable", !available);
item.setAttribute("aria-hidden", String(!available));
});
const motorName = motors[axis]?.name || axis;
const twinLabel = axis === "longitudinal"
? `${motorName}位置`
: ["cushion_tilt", "backrest_recline", "legrest_height"].includes(axis)
? `${motorName}夹角`
: motorName;
$$(`[data-twin-axis="${axis}"] > span, [data-callout="${axis}"] > span`)
.forEach((label) => { label.textContent = twinLabel; });
});
const cushionName = motors.cushion_tilt?.name;
const backrestName = motors.backrest_recline?.name;
const legrestName = motors.legrest_height?.name;
const backrestIncludedLabel = $('[data-callout="backrest_included_angle"] > span');
const legrestIncludedLabel = $('[data-callout="legrest_included_angle"] > span');
if (backrestIncludedLabel && backrestName && cushionName) {
backrestIncludedLabel.textContent = `${backrestName}-${cushionName}夹角`;
}
if (legrestIncludedLabel && cushionName && legrestName) {
legrestIncludedLabel.textContent = `${cushionName}-${legrestName}夹角`;
}
}
function renderSeatMotorRows(vehicle) {
const motors = Object.entries(vehicle?.seat?.motors || {});
$$(".seat-card .motion-state-grid").forEach((grid) => {
const fragment = document.createDocumentFragment();
motors.forEach(([axis, motor]) => {
const item = document.createElement("div");
item.className = "motion-axis";
item.dataset.axis = axis;
const label = document.createElement("span");
label.textContent = motor?.name || axis;
const value = document.createElement("b");
value.textContent = "--";
item.append(label, value, document.createElement("i"));
fragment.append(item);
});
grid.replaceChildren(fragment);
});
}
function selectVehicle(vehicleId, { silent = false } = {}) {
const vehicle = findVehicleProfile(vehicleId);
if (!vehicle) return;
@@ -2468,6 +2612,8 @@ function selectVehicle(vehicleId, { silent = false } = {}) {
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
@@ -2476,7 +2622,7 @@ function selectVehicle(vehicleId, { silent = false } = {}) {
if (state.result?.actuator_state) {
renderSeatState(state.result.actuator_state);
} else if (state.seatTwin?.actuatorState) {
updateSeatTwin(state.seatTwin.actuatorState);
renderSeatState(state.seatTwin.actuatorState);
}
if (!silent) showToast(`已切换车型:${vehicle.name}`);
}
@@ -2767,10 +2913,41 @@ function resetSeatState() {
});
}
function mergeSeatActuatorState(previousState, patchState) {
const mergedState = { ...(previousState || {}) };
Object.entries(patchState || {}).forEach(([seat, functions]) => {
const previousSeat = mergedState[seat] || {};
const mergedSeat = { ...previousSeat };
Object.entries(functions || {}).forEach(([feature, value]) => {
if (feature === "position") {
const previousPosition = previousSeat.position || {};
const mergedPosition = { ...previousPosition };
Object.entries(value || {}).forEach(([axis, motion]) => {
mergedPosition[axis] = motion && typeof motion === "object"
? { ...(previousPosition[axis] || {}), ...motion }
: motion;
});
mergedSeat.position = mergedPosition;
return;
}
mergedSeat[feature] = value && typeof value === "object" && !Array.isArray(value)
? { ...(previousSeat[feature] || {}), ...value }
: value;
});
mergedState[seat] = mergedSeat;
});
return mergedState;
}
function renderSeatState(actuatorState) {
resetSeatState();
if (!actuatorState) return;
Object.entries(actuatorState).forEach(([seat, functions]) => {
const mergedActuatorState = mergeSeatActuatorState(
state.seatActuatorState,
actuatorState,
);
state.seatActuatorState = mergedActuatorState;
resetSeatState();
Object.entries(mergedActuatorState).forEach(([seat, functions]) => {
Object.entries(functions).forEach(([feature, value]) => {
if (feature === "position") {
Object.entries(value).forEach(([axis, motion]) => {
@@ -2793,7 +2970,7 @@ function renderSeatState(actuatorState) {
$("b", item).textContent = value.state === "on" ? `ON · L${value.level}` : "OFF";
});
});
updateSeatTwin(actuatorState);
updateSeatTwin(mergedActuatorState);
}
function renderDomainState(domainState) {